How to use net package for TCP

Use net.Dial to establish a TCP connection and the Conn interface to read and write data.

TCP connections in Go

You've built a web server. You've made HTTP requests. Now you need to talk to a service that doesn't speak HTTP. Maybe it's a legacy database, a custom game protocol, or a simple text-based command interface. You need a raw TCP connection. Go's net package gives you exactly that, with zero ceremony and high performance.

TCP is a reliable byte stream. Think of it as a digital pipe. You pour bytes into one end, and they emerge from the other end in the exact same order, without gaps or duplicates. The net package abstracts this pipe behind the net.Conn interface. net.Dial creates the pipe and connects it to a remote address. Once connected, you read and write bytes. That's the whole model. No hidden state machines. No complex handshake management. Just a stream.

Minimal client

Here's the simplest client: dial a server, send a message, read the response, and close the connection.

package main

import (
    "fmt"
    "net"
)

func main() {
    // Dial establishes a TCP connection to the target address.
    conn, err := net.Dial("tcp", "example.com:80")
    if err != nil {
        fmt.Println("dial failed:", err)
        return
    }
    // Close the connection when the function exits.
    defer conn.Close()

    // Write sends the request payload to the server.
    _, err = conn.Write([]byte("GET / HTTP/1.0\r\n\r\n"))
    if err != nil {
        fmt.Println("write failed:", err)
        return
    }
}

After writing, you need to read the response. Read fills a buffer with data from the server.

// Read blocks until data arrives or an error occurs.
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
    fmt.Println("read failed:", err)
    return
}

// Print only the bytes that were actually read.
fmt.Print(string(buf[:n]))

Dial, write, read, close. The lifecycle is linear.

How the runtime handles connections

When you call net.Dial, the runtime resolves the hostname via DNS, performs the TCP three-way handshake, and returns a net.Conn. The defer conn.Close() ensures the connection closes when main returns, preventing resource leaks.

Go uses non-blocking I/O under the hood. When a goroutine calls Read, the runtime parks the goroutine and registers the file descriptor with the OS event loop. When data arrives, the runtime wakes the goroutine. This allows thousands of concurrent connections with minimal memory overhead. You don't manage threads. You manage goroutines.

The error handling pattern if err != nil { ... } is verbose, but it forces you to handle the unhappy path explicitly. The Go community accepts this boilerplate because it makes failures visible. Every network operation can fail. Checking the error is part of the logic.

The net.Conn interface

The net.Conn interface defines the contract for all network connections. It exposes five core methods. Read and Write move data. Close terminates the connection. LocalAddr and RemoteAddr return address information. The deadline methods control timeouts.

SetDeadline affects both read and write operations. SetReadDeadline affects only reads. SetWriteDeadline affects only writes. Deadlines are absolute times, not durations. You set a deadline to time.Now().Add(5 * time.Second). Once the deadline passes, operations return a timeout error. This is crucial for preventing goroutine leaks. If a read blocks forever, the goroutine blocks forever. Deadlines force the operation to return.

Trust gofmt. Argue logic, not formatting. When you write network code, you'll likely import multiple packages. gofmt groups imports automatically. Most editors run it on save. Don't fight the formatter.

Handling the byte stream

TCP is a stream, not a message protocol. Read returns as soon as it has some data, not necessarily all the data you expect. If the server sends 100 bytes, Read might return 10. You need a loop or io.ReadFull to get the complete message.

For line-based protocols, wrap the connection in a bufio.Reader. The buffered reader handles partial reads internally and provides convenient methods like ReadString.

import (
    "bufio"
    "net"
)

// Wrap the connection in a buffered reader.
reader := bufio.NewReader(conn)

// ReadString returns data up to the delimiter.
// It buffers internally to handle partial reads.
line, err := reader.ReadString('\n')
if err != nil {
    return err
}

When you write data, Write returns the number of bytes written and an error. If the error is nil, the return value equals the length of the input. You can discard the byte count with _. _, err = conn.Write(data). This signals you considered the return value and chose to ignore it. The underscore discards a value intentionally. Use it sparingly with errors, but it's standard for write counts.

For forwarding data between connections, use io.Copy. It reads from the source and writes to the destination until EOF, using an internal buffer.

import (
    "io"
    "net"
)

// io.Copy reads from src and writes to dst until EOF.
// It uses an internal buffer, so no manual loop is needed.
n, err := io.Copy(dst, src)
if err != nil {
    return err
}

Read returns what it has, not what you want. Loop until done.

Realistic client with context

Real applications need timeouts and cancellation. net.Dial blocks indefinitely if the server is unreachable. Use net.Dialer to configure timeouts, and pass a context.Context to DialContext.

package main

import (
    "context"
    "fmt"
    "net"
    "time"
)

// connectWithTimeout dials a TCP address with a deadline.
func connectWithTimeout(ctx context.Context, addr string) (net.Conn, error) {
    // Dialer configures connection parameters.
    d := net.Dialer{
        Timeout:   5 * time.Second,
        KeepAlive: 30 * time.Second,
    }

    // DialContext respects the context for cancellation.
    // Context must be the first argument by convention.
    conn, err := d.DialContext(ctx, "tcp", addr)
    if err != nil {
        return nil, fmt.Errorf("dial %s: %w", addr, err)
    }

    return conn, nil
}

DialContext allows the connection attempt to be cancelled. The context.Context is always the first parameter in Go functions that support cancellation. This convention lets callers pass deadlines or cancellation signals uniformly. Functions that take a context should respect cancellation and deadlines.

Context is plumbing. Run it through every long-lived call site.

Errors and timeouts

Network errors require careful handling. The net.Error interface wraps network errors. You can call Timeout() to check if the error is due to a deadline. You can call Temporary() to check if the error might resolve later. This distinction matters for retry logic. A timeout might mean the network is slow; retrying could help. A connection refused error means the server is down; retrying immediately is useless.

If you try to read from a closed connection, you get an error like read tcp 127.0.0.1:8080->127.0.0.1:54321: use of closed network connection. If the server drops the connection, Read returns io.EOF. This signals the remote side closed the stream gracefully. io.EOF is not an error in the traditional sense; it's a signal. Handle it based on your protocol.

If you pass a wrong address format, the compiler won't catch it, but Dial will fail at runtime with dial tcp: address example.com:99999: invalid port. If the host is unreachable, you might see dial tcp 192.168.1.1:8080: connect: connection refused. If the DNS lookup fails, the error is dial tcp: lookup example.com: no such host.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. In network code, this means using deadlines or context cancellation to unblock reads and writes.

Decision matrix

Use net.Dial for quick scripts or simple connections where you control the lifetime and don't need fine-grained timeout control. Use net.Dialer with DialContext when you need timeouts, keep-alives, or cancellation support in production code. Use bufio.Reader wrapped around net.Conn when you need to read lines or delimited messages efficiently without manual buffer management. Use http.Client when the remote service speaks HTTP; don't reinvent the protocol stack for standard web traffic. Use a connection pool when you need high throughput to the same host; creating TCP connections is expensive due to the handshake latency. Use net.Listen when you need to accept incoming connections and build a server.

Don't fight the stream. Wrap the reader or change the protocol.

Where to go next