---
title: How to Create a TCP Client in Go
summary: Use the `net.Dial` function from the standard library to establish a TCP connection, then wrap the returned `net.Conn` interface with `bufio` readers and writers for efficient I/O.
lang: en
slug: how-to-create-a-tcp-client-in-go
published: 2026-04-17T16:32:01+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.gofaq.org/en/how-to-create-a-tcp-client-in-go/
---

# How to Create a TCP Client in Go



## Connecting to a TCP Service

You need to talk to a service that doesn't speak HTTP. Maybe a game server, a database on a custom port, or a legacy daemon waiting for a handshake. You have an IP and a port. You need to open a pipe, push data through, and read what comes back. Go's standard library handles the socket plumbing so you don't have to wrestle with file descriptors or blocking modes.

TCP is a reliable byte stream. Think of it like a dedicated phone line. Once the connection is established, data flows both ways in order. You don't worry about packets getting lost or arriving out of sequence. The network layer handles that. In Go, `net.Conn` is the interface that represents this line. It abstracts away the operating system details. You get a single object that you can read from and write to.

Here's the skeleton: dial a host, wrap the connection for buffered I/O, send a message, flush the buffer, and read the response.

```go
package main

import (
	"bufio"
	"fmt"
	"net"
	"os"
)

func main() {
	// Connect to the server. "tcp" specifies the protocol.
	conn, err := net.Dial("tcp", "localhost:9000")
	if err != nil {
		fmt.Fprintln(os.Stderr, "failed to connect:", err)
		os.Exit(1)
	}
	// Close the connection when main returns to free resources.
	defer conn.Close()

	// Wrap the connection for efficient text I/O.
	scanner := bufio.NewScanner(conn)
	writer := bufio.NewWriter(conn)

	// Send a message. Newline signals the end of the line.
	_, err = writer.WriteString("ping\n")
	if err != nil {
		fmt.Fprintln(os.Stderr, "write failed:", err)
		return
	}
	// Push buffered data to the socket immediately.
	if err := writer.Flush(); err != nil {
		fmt.Fprintln(os.Stderr, "flush failed:", err)
		return
	}

	// Read one line from the server.
	if scanner.Scan() {
		fmt.Println("server said:", scanner.Text())
	}
}
```

## How the connection works

`net.Dial` does the heavy lifting. It creates a socket, configures it for TCP, and attempts the three-way handshake. If the server isn't listening or the network is down, it returns an error. The success case gives you a `net.Conn`. This interface defines `Read`, `Write`, `Close`, and a few others. It works for TCP, UDP, and Unix sockets, so your code stays the same if you swap transports later.

Convention aside: `defer conn.Close()` is the standard pattern. Place it right after the error check. It ensures cleanup happens when the function returns, even if you return early due to an error. The `defer` statement schedules the call; it doesn't run immediately. This keeps the resource open for the rest of the function while guaranteeing release.

The `net.Conn` interface is a win for testing. Go follows the mantra "accept interfaces, return structs." `net.Dial` returns an interface, not a concrete type. You can pass a mock `net.Conn` to your logic functions. This lets you test client code without spinning up a real server. Trust the interface. Mock it in tests, dial it in production.

Buffering matters for performance. System calls are expensive. Calling `conn.Write` for every byte triggers a syscall each time. `bufio.NewWriter` batches writes in memory. You call `Flush` to push the batch to the socket. For text protocols, `bufio.Scanner` reads line by line efficiently. It handles the buffering and tokenization for you.

Ah-ha reveal: `conn.Write` does not guarantee sending all bytes. It returns the number of bytes written and an error. If the error is nil but the count is less than the buffer length, you have a partial write. The compiler won't warn you. If you ignore the count, you lose data. This is different from Python's `socket.send` wrappers that often handle the loop automatically. `bufio.Writer` hides this complexity by looping internally until all data is sent or an error occurs. This is another reason to prefer `bufio` for text protocols.

## Adding timeouts and context

Production code needs timeouts and cancellation. Here's how to wire `context` into the dialer and set a deadline on the connection.

```go
package main

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

// ConnectWithTimeout dials a TCP address with a deadline.
func ConnectWithTimeout(ctx context.Context, addr string) (net.Conn, error) {
	// Dialer respects context cancellation and deadlines.
	dialer := net.Dialer{
		Timeout:   5 * time.Second,
		KeepAlive: 30 * time.Second,
	}
	// DialContext integrates with the context for cancellation.
	return dialer.DialContext(ctx, "tcp", addr)
}

func main() {
	// Create a context with a deadline for the whole operation.
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	conn, err := ConnectWithTimeout(ctx, "localhost:9000")
	if err != nil {
		fmt.Fprintln(os.Stderr, "connection failed:", err)
		os.Exit(1)
	}
	defer conn.Close()

	// Set read deadline on the connection for the response.
	if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
		fmt.Fprintln(os.Stderr, "deadline error:", err)
		return
	}

	scanner := bufio.NewScanner(conn)
	writer := bufio.NewWriter(conn)

	_, err = writer.WriteString("status\n")
	if err != nil {
		fmt.Fprintln(os.Stderr, "write error:", err)
		return
	}
	if err := writer.Flush(); err != nil {
		fmt.Fprintln(os.Stderr, "flush error:", err)
		return
	}

	if scanner.Scan() {
		fmt.Println("response:", scanner.Text())
	}
}
```

`net.Dialer` lets you configure the connection before dialing. The `Timeout` field sets the maximum time for the dial to complete. `KeepAlive` enables TCP keep-alive probes to detect dead connections. `DialContext` accepts a `context.Context`. If the context is cancelled, the dial aborts. This is essential for web servers that spawn client connections per request. If the client cancels the request, the dial stops.

Convention aside: `context.Context` always goes as the first parameter, conventionally named `ctx`. Functions that take a context should respect cancellation and deadlines. This allows callers to control the lifecycle of the operation. The `ConnectWithTimeout` function follows this pattern.

`SetReadDeadline` sets a deadline for future reads. If the server doesn't respond in time, the read returns an error instead of blocking forever. This prevents goroutines from hanging indefinitely. You can reset the deadline for each read if you're processing a stream.

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

## Pitfalls and errors

Forgetting `Flush` is the silent killer. `bufio.Writer` holds data in memory. If you write and exit without flushing, the server never sees the message. The compiler won't catch this. You just get a timeout on the read. Always flush after writing, or use `defer writer.Flush()` if the writer lives for the duration of the function.

`bufio.Scanner` returns false when it hits EOF or an error. You must check `scanner.Err()` to distinguish between the two. If the server closes the connection cleanly, `Scan` returns false and `Err` is nil. If the network drops, `Err` contains the error. Ignoring `Err` hides failures.

```go
if scanner.Scan() {
    fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
    fmt.Fprintln(os.Stderr, "read error:", err)
}
```

`bufio.Scanner` has a default buffer size of 64KB. If the server sends a line longer than that, `Scan` fails with `bufio: token too long`. You'd need `scanner.Buffer` to increase the limit or a custom reader for massive payloads.

`net.Dial` blocks until connected. Without a timeout, your program hangs forever if the server is down. Always use `net.Dialer` with a timeout in real code. If you use `net.Dial` directly, the compiler won't complain, but your runtime will stall.

Common runtime errors include `dial tcp 127.0.0.1:9000: connect: connection refused` when the server isn't listening, and `read tcp 127.0.0.1:12345->127.0.0.1:9000: connection reset by peer` when the server closes the connection abruptly. The compiler rejects undefined variables with `undefined: pkg` and unused imports with `imported and not used`. These are compile-time checks that keep your code clean.

Convention aside: `if err != nil { return err }` is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Errors are values in Go. Handle them explicitly. Don't swallow them.

Flush your writer. Check scanner errors. Timeouts save lives.

## When to use what

Use `net.Dial` when you need a quick TCP connection with default settings and the server is local or highly reliable. Use `net.Dialer` when you must configure timeouts, keep-alives, or local bind addresses before connecting. Use `bufio.Scanner` and `bufio.Writer` when working with line-based text protocols to reduce system calls and handle partial writes automatically. Use raw `conn.Read` and `conn.Write` when sending binary frames or when you need precise control over byte boundaries. Use `context.Context` with `DialContext` when the connection must be cancellable or bound to a request lifecycle. Use `net/http` when the server speaks HTTP; don't reinvent the protocol over raw TCP.

Use the right tool for the transport. Raw TCP for control, HTTP for standards.

## Where to go next

- [How to Make an HTTP GET Request in Go](/en/how-to-make-an-http-get-request-in-go/)
- [How to Implement a Port Scanner in Go](/en/how-to-implement-a-port-scanner-in-go/)
- [How to Serve HTML Pages from a Go HTTP Server](/en/how-to-serve-html-pages-from-a-go-http-server/)