The socket is just a file
You are building a tool that needs to talk to another machine. Maybe it is a simple health checker, a custom protocol handler, or a background service that exchanges binary packets. You do not want to wrestle with raw sockets, file descriptors, or platform-specific system calls. You just want to open a pipe, send some bytes, and close it when you are done. The net package gives you exactly that. It wraps the operating system's networking stack behind a small, predictable set of types and functions. You get TCP, UDP, IP, and Unix sockets without writing a single bind or connect syscall yourself.
How net abstracts the network
Think of a network connection like a telephone line. Before you can talk, someone has to install the line and plug it into a wall. That is the listener. When a call comes in, the phone rings. You pick it up. That is accepting a connection. Once the line is open, you can speak and listen freely. When you hang up, the line goes dead and the resources are freed. The net package handles the wiring, the ringing, and the hang-up. You only deal with the conversation.
The core abstraction is the Conn interface. It defines Read, Write, Close, LocalAddr, RemoteAddr, and SetDeadline. Every network connection in Go, whether it travels over Wi-Fi, a fiber optic cable, or a local Unix socket, implements this interface. The Listener interface is similar but focuses on Accept and Close. This uniformity means you can write a function that processes a net.Conn and it will work for TCP, UDP, or Unix sockets without changing a single line of code.
TCP and UDP behave differently under the hood, but net exposes them through the same methods. TCP guarantees delivery and order. It blocks until the remote side acknowledges your data. UDP sends datagrams and forgets about them. It does not guarantee delivery or order. The net package does not hide these differences. It gives you the same Read and Write signatures, but the underlying OS behavior changes based on the protocol string you pass to Listen or Dial. You choose the protocol based on whether you need reliability or speed.
A minimal TCP server
Here is a bare-bones TCP server that listens on port 8080, accepts one connection, sends a greeting, reads a reply, and shuts down.
package main
import (
"fmt"
"io"
"net"
)
// runServer starts a TCP listener, accepts one connection, and exchanges a message.
func runServer() {
// Listen on all interfaces on port 8080 using TCP.
listener, err := net.Listen("tcp", ":8080")
if err != nil {
// Exit immediately if the port is unavailable or permissions fail.
panic(err)
}
// Ensure the listener closes when the function returns.
defer listener.Close()
// Block until a client completes the TCP handshake.
conn, err := listener.Accept()
if err != nil {
panic(err)
}
// Close the connection when we are done handling it.
defer conn.Close()
// Send a greeting to the client.
conn.Write([]byte("Hello from server\n"))
// Read up to 1024 bytes from the client.
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil && err != io.EOF {
// Network errors surface here. EOF means the client closed cleanly.
panic(err)
}
fmt.Printf("Received: %s\n", string(buf[:n]))
}
func main() {
runServer()
}
Walk through the connection lifecycle
The program starts by calling net.Listen("tcp", ":8080"). The first argument specifies the protocol. The second argument is the address. A colon followed by a port number tells the OS to bind to all available network interfaces. If the port is already in use, the call fails immediately. The compiler will not catch this. You get a runtime error like listen tcp :8080: bind: address already in use.
Once the listener is ready, listener.Accept() blocks. It waits for an incoming TCP handshake to complete. When a client connects, the OS creates a new socket for that specific conversation and returns it as a net.Conn. The original listener stays open, ready to accept the next call. This is why production servers run Accept inside a loop.
The Read and Write methods work exactly like file I/O. Read fills your byte slice and returns the number of bytes copied. It might return fewer bytes than requested if the network buffer is empty or the connection drops. Write sends data to the remote side. Both methods can block indefinitely if the network stalls. They can also return partial reads or writes. The io package provides helpers like io.ReadFull to handle the repetition, but understanding the raw behavior prevents subtle bugs.
Always check the return value of Read. The slice you pass in is just a destination buffer. The network stack delivers data in chunks. If you allocate 1024 bytes and only 50 arrive, n will be 50. Slicing buf[:n] gives you exactly what arrived. Slicing buf[:1024] exposes uninitialized memory or leftover data from a previous read. The compiler cannot verify network behavior at build time. You must handle the byte count at runtime.
Realistic: handling multiple clients
A single-connection server is fine for testing. Real services handle dozens or thousands of concurrent clients. You need a loop to accept connections and a goroutine to handle each one independently. Otherwise, the server processes clients sequentially, which defeats the purpose of networking.
package main
import (
"fmt"
"io"
"net"
)
// handleConnection reads data from a client and echoes it back.
func handleConnection(conn net.Conn) {
// Close the connection as soon as the handler finishes.
defer conn.Close()
// Create a reusable buffer for reading.
buf := make([]byte, 1024)
for {
// Read incoming data. Break on EOF or error.
n, err := conn.Read(buf)
if err != nil {
if err != io.EOF {
// Log unexpected network failures without crashing.
fmt.Printf("read error: %v\n", err)
}
break
}
// Echo the exact bytes back to the client.
conn.Write(buf[:n])
}
}
// runEchoServer listens on a port and spawns a goroutine per client.
func runEchoServer() {
listener, err := net.Listen("tcp", ":9090")
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Println("Echo server listening on :9090")
for {
// Accept blocks until a new TCP connection arrives.
conn, err := listener.Accept()
if err != nil {
// Log accept errors and keep the server alive.
fmt.Printf("accept error: %v\n", err)
continue
}
// Handle each connection concurrently without blocking the accept loop.
go handleConnection(conn)
}
}
func main() {
runEchoServer()
}
The for loop keeps the listener alive. Each successful Accept spawns a new goroutine. Goroutines are cheap in Go, so spawning one per connection is the standard pattern. The handleConnection function runs its own read loop. When the client closes the connection, conn.Read returns io.EOF. The handler breaks out of the loop, the deferred conn.Close() runs, and the goroutine exits. The OS reclaims the socket resources.
Pitfalls, errors, and conventions
Network code fails in predictable ways. The most common mistake is ignoring partial reads. If you allocate a 1024-byte slice and call Read, you cannot assume n will equal 1024. The network stack delivers data in chunks. If you slice buf[:1024] instead of buf[:n], you will read uninitialized memory or leftover data from a previous read. Always use the returned byte count.
Another trap is connection leaks. If a goroutine blocks on Read or Write and the client disappears without sending a close signal, that goroutine hangs forever. The socket stays open, the file descriptor count climbs, and the process eventually hits the OS limit. The compiler cannot prevent this. You get a silent resource leak. The fix is to set deadlines or use a context. Calling conn.SetReadDeadline(time.Now().Add(5 * time.Second)) forces the read to fail with a timeout error if no data arrives. The handler can then clean up and exit.
Error handling in Go follows a strict pattern. You check err != nil immediately after every call that can fail. The community accepts the repetition because it forces you to acknowledge failure paths. You will see if err != nil { return err } dozens of times in network code. Do not swallow errors with _. If you ignore a dial or accept error, your program will panic later with something like runtime error: invalid memory address or nil pointer dereference when it tries to use a nil connection.
The net package also enforces a naming convention for addresses. You pass strings like :8080, 127.0.0.1:8080, or localhost:8080 to Listen and Dial. The package parses these strings and resolves hostnames using the system's DNS configuration. If the hostname does not resolve, you get a runtime error like dial tcp: lookup unknown-host: no such host. The compiler treats these as plain strings, so it cannot validate them at build time.
Resource cleanup follows a simple rule. Every net.Conn and net.Listener must be closed exactly once. The defer statement is the standard tool for this. Place defer conn.Close() immediately after a successful Accept or Dial. This guarantees cleanup even if a panic occurs later in the handler. The Go community treats deferred cleanup as mandatory for network resources. Skip it and you will leak file descriptors under load.
When to reach for net
Use net.Listen and net.Dial when you need raw TCP or UDP sockets for custom protocols, binary data exchange, or low-latency communication. Use net/http when you are building a web server or client that speaks HTTP, HTTPS, or REST APIs. Use a higher-level framework like Echo or Gin when you need routing, middleware, request binding, and automatic JSON marshaling. Use Unix sockets via net.Listen("unix", "/tmp/app.sock") when both processes run on the same machine and you want to skip the network stack entirely. Use plain sequential code when you only need to make a single outbound request and can afford to block the current goroutine.
Network I/O is inherently asynchronous and unreliable. Write handlers that expect partial data, set deadlines to prevent hangs, and close connections explicitly. The net package gives you the pipes. You provide the plumbing.