When localhost TCP is too slow or too exposed
You have a background worker that processes jobs and a web server that receives API requests. Both run on the same machine. You want the web server to hand off work to the worker without exposing a TCP port to the network. Opening a port on localhost feels like overkill and adds latency. You need a way for two processes on the same host to talk fast and securely, using the filesystem as the address book.
Unix domain sockets solve this problem. They provide a communication channel between processes on the same machine that bypasses the network stack entirely. The data never leaves the kernel. There are no IP headers, no checksums, no routing tables, and no TCP handshakes. The "address" is a file path on the disk. If a process can write to the directory, it can connect. This makes Unix sockets faster than localhost TCP and naturally restricted to local processes.
The socket file is the address
Think of a Unix socket like a private drop box installed in a hallway. Only people inside the building can reach it. You don't need a mailing address or a postal service. You walk to the box, drop a message, and the recipient picks it up. The box itself is the address.
In Go, the socket file lives on the filesystem. When you create a listener, the OS creates a file at the specified path and marks it as a socket type. Clients connect by opening that path. The file persists after the process exits, which causes a common gotcha: if you restart the server without cleaning up the old file, the OS thinks the address is still in use. You must remove the file before binding, or handle the error gracefully.
Minimal listener and client
Here's the simplest server: spawn a listener, accept a connection, and print a message. The code uses net.ListenUnix to get a *net.UnixListener, which gives access to Unix-specific methods.
package main
import (
"fmt"
"net"
)
// main sets up a listener on a Unix socket and accepts one connection.
func main() {
// Remove the socket file if it exists from a previous run.
// Unix sockets create a file on disk; leftover files cause bind errors.
_ = net.RemoveUnix("/tmp/gofaq.sock")
// ListenUnix creates a listener bound to a filesystem path.
// The "unix" network string selects the Unix domain socket protocol.
listener, err := net.ListenUnix("unix", &net.UnixAddr{Net: "unix", Name: "/tmp/gofaq.sock"})
if err != nil {
panic(err)
}
defer listener.Close()
// AcceptUnix blocks until a client connects.
// It returns a *UnixConn that implements net.Conn.
conn, err := listener.AcceptUnix()
if err != nil {
panic(err)
}
defer conn.Close()
fmt.Println("Client connected via Unix socket")
}
The client connects using net.DialUnix. The local address is nil because the client doesn't need a specific path; the kernel assigns a temporary one if needed.
package main
import (
"fmt"
"net"
)
// main connects to a Unix socket and sends a message.
func main() {
// DialUnix connects to the socket file path.
// The first argument is nil because the client address is ephemeral.
conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Net: "unix", Name: "/tmp/gofaq.sock"})
if err != nil {
panic(err)
}
defer conn.Close()
// Write a payload to the server.
// Unix sockets support streaming writes just like TCP.
conn.Write([]byte("hello"))
fmt.Println("Message sent")
}
Run the server in one terminal and the client in another. The server prints the connection message immediately. The socket file appears at /tmp/gofaq.sock while the server runs.
What happens under the hood
When net.ListenUnix executes, the Go runtime calls the OS to create a socket and bind it to the path. The OS creates the file and marks it as a socket. The listener starts waiting for connections. When a client calls DialUnix, the kernel establishes a direct pipe between the two processes. Data moves via sendmsg and recvmsg system calls, copying bytes between user space and kernel space with minimal overhead.
The connection behaves like any other net.Conn. You can read, write, and close it using the same methods you'd use for TCP. The io.Reader and io.Writer interfaces work identically. This means you can wrap a Unix socket connection in bufio, json.Encoder, or any standard library tool without changes. The transport is different, but the interface is the same.
Convention aside: net.Listen returns a net.Listener interface. net.ListenUnix returns a *net.UnixListener. If you don't need Unix-specific features, use net.Listen("unix", ...) to keep the code generic. This follows the "accept interfaces, return structs" mantra. Factory functions should return net.Listener, not concrete types, so callers can swap transports without refactoring.
Passing file descriptors with SCM_RIGHTS
Unix sockets can do something TCP cannot: pass open file descriptors between processes. This feature is called SCM_RIGHTS. You can send a file descriptor from a server to a client, and the client gets a copy of that descriptor. This is how service managers hand off listening sockets to workers. The worker inherits the socket without opening a new one, which allows zero-downtime restarts.
Passing a file descriptor requires out-of-band data. You use WriteMsgUnix to send control messages alongside the payload. The control message contains the file descriptor encoded by net.UnixRights.
package main
import (
"log"
"net"
"os"
)
// sendFD sends an open file descriptor to the connected client.
// This uses SCM_RIGHTS, which is only available on Unix sockets.
func sendFD(conn *net.UnixConn, fd int) error {
// UnixRights encodes the file descriptor into the OOB buffer format.
// The kernel interprets this buffer as a control message.
oob := net.UnixRights(fd)
// WriteMsgUnix sends the control message with an empty payload.
// The payload can be empty; the OOB data carries the fd.
// The third argument is the destination address, which is nil for connected sockets.
_, _, err := conn.WriteMsgUnix(nil, oob, nil)
return err
}
// main demonstrates sending a file descriptor to a client.
func main() {
_ = net.RemoveUnix("/tmp/gofaq.sock")
listener, err := net.ListenUnix("unix", &net.UnixAddr{Net: "unix", Name: "/tmp/gofaq.sock"})
if err != nil {
log.Fatal(err)
}
defer listener.Close()
conn, err := listener.AcceptUnix()
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// Open a file to send.
// The client will receive a copy of this file descriptor.
f, err := os.Open("/etc/hostname")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Send the file descriptor to the client.
err = sendFD(conn, int(f.Fd()))
if err != nil {
log.Fatal(err)
}
log.Println("File descriptor sent")
}
The client receives the file descriptor using ReadMsgUnix. It extracts the rights from the OOB buffer and converts them back to file descriptors. This pattern is powerful for sharing resources across process boundaries without serialization.
Pitfalls and permissions
Unix sockets create real files on the filesystem. This brings filesystem concerns into your network code. The socket file gets default permissions based on the process's umask. If the umask is restrictive, other users might not be able to connect. Use os.Chmod after binding to set the correct permissions, or set the umask before starting the listener.
// Set permissions so other users can connect.
// 0666 allows read/write for everyone, but the socket file type restricts usage.
os.Chmod("/tmp/gofaq.sock", 0666)
Leftover files are the most common error. If the server crashes without cleaning up, the socket file remains. The next start fails with listen unix /tmp/gofaq.sock: bind: address already in use. Always remove the file before binding, or check if the error is EADDRINUSE and retry after removal.
The compiler won't catch these issues. They are runtime errors. The compiler rejects type mismatches with messages like cannot use x (untyped int constant) as string value in argument, but it doesn't validate socket paths or permissions. You must handle errors explicitly. The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible.
Another pitfall is assuming Unix sockets work everywhere. They are a POSIX feature. Windows has limited support, and some embedded systems lack them entirely. If your code must run on Windows, fall back to TCP on localhost.
Decision matrix
Use a Unix domain socket when two processes run on the same machine and you want lower latency than TCP. Use a Unix domain socket when you need filesystem-based permissions to control access, since the socket is a file. Use a Unix domain socket when you want to pass file descriptors between processes using SCM_RIGHTS, which Unix sockets support natively. Use TCP on localhost when you need the code to work identically across platforms, including Windows where Unix sockets have limited or no support. Use TCP on localhost when the client and server might eventually move to different machines, so you don't have to refactor the transport layer later. Use a shared memory segment or mmap when you need maximum throughput and can handle the complexity of synchronization without network-like abstractions.
Unix sockets are fast. They are also local. Don't use them if you plan to scale horizontally tomorrow.