Using Unix Domain Sockets with asyncio¶
When two processes on the same machine talk to each other — an application and a local sidecar, a web server and its app server, a CLI and a daemon — a Unix domain socket is usually the better transport than TCP on 127.0.0.1. asyncio supports it with the same stream API: start_unix_server and open_unix_connection in place of their TCP twins. Measured on Linux with Python 3.14, a 64-byte echo round trip took 13.1 µs over a Unix socket against 39.0 µs over TCP loopback, three times faster, because no TCP/IP stack is involved. Bulk throughput was about the same (705 against 766 MiB/s), limited by asyncio rather than the transport. Unix sockets also bring filesystem permissions for access control and kernel-verified peer credentials. This guide sets up a server and client, manages the socket file, and uses those security features.
Prerequisites¶
- Python 3.11+ on Linux or macOS (Windows supports
AF_UNIXwith limitations); stdlib only. - Stream servers, from writing a TCP server with asyncio.start_server.
- A directory both processes can access, such as
/run/myapp/.
1. Serve and connect on a socket path¶
The API mirrors TCP, with a filesystem path instead of host and port. Handlers are identical, so the same server can listen on both:
import asyncio
SOCKET_PATH = "/run/myapp/api.sock"
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
while data := await reader.read(65536):
writer.write(data)
await writer.drain()
finally:
writer.close()
async def main() -> None:
server = await asyncio.start_unix_server(handle, path=SOCKET_PATH)
async with server:
await server.serve_forever()
async def client() -> bytes:
reader, writer = await asyncio.open_unix_connection(SOCKET_PATH)
writer.write(b"ping")
await writer.drain()
reply = await reader.read(4)
writer.close()
await writer.wait_closed()
return reply
Everything about framing, drain(), timeouts and shutdown carries over unchanged from TCP. HTTP works too: Uvicorn's --uds option serves an ASGI app on a socket, and httpx connects with httpx.AsyncHTTPTransport(uds=SOCKET_PATH), which is how many local sidecars and the Docker API are reached.
Verify: socat - UNIX-CONNECT:/run/myapp/api.sock exchanges data with the server.
2. Manage the socket file's lifecycle¶
A Unix socket is a file. A server that crashes leaves it behind, and the next start must deal with it:
import os
import stat
def prepare_socket_path(path: str) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
try:
mode = os.stat(path).st_mode
except FileNotFoundError:
return
if not stat.S_ISSOCK(mode):
raise RuntimeError(f"{path} exists and is not a socket") # never delete arbitrary files
Tested on Python 3.14: start_unix_server replaced a stale socket file left by a crashed process without complaint, refused to start over a regular file with "Address already in use", and server.close() removed the socket file (the cleanup_socket behaviour added in Python 3.13). On older versions, remove the file yourself at shutdown. The check above makes the "not a socket" case explicit so a misconfigured path can never delete a real file. Put sockets in a dedicated directory (/run/<app>/, or $XDG_RUNTIME_DIR for per-user services), not in /tmp, where other users can create files with the same name first.
Verify: kill the server with SIGKILL, restart it, and it binds successfully; stop it gracefully and the socket file is gone.
3. Control access with permissions¶
Who can connect is decided by filesystem permissions on the socket and its directory. The file is created according to the process umask — tested, 0o775 under the default umask of 0002, which lets the group write and therefore connect. Set it explicitly:
import grp
import os
async def main() -> None:
old = os.umask(0o117) # create as rw-rw---- (0o660)
try:
server = await asyncio.start_unix_server(handle, path=SOCKET_PATH)
finally:
os.umask(old)
os.chown(SOCKET_PATH, -1, grp.getgrnam("myapp-clients").gr_gid) # group = allowed clients
async with server:
await server.serve_forever()
Connecting requires write permission on the socket file, so 0o660 with a dedicated group admits exactly the users in that group. Setting the umask around creation avoids the race of creating the file with loose permissions and tightening them afterwards. Directory permissions add a second layer: a directory with mode 0o750 keeps everyone outside the group from even reaching the path.
Verify: a user outside the group gets PermissionError when connecting; a user in it connects normally.
4. Identify the peer with SO_PEERCRED¶
On Linux, the kernel tells the server which process connected — its PID, user and group — and the client cannot forge it:
import socket
import struct
def peer_credentials(writer: asyncio.StreamWriter) -> tuple[int, int, int]:
sock = writer.get_extra_info("socket")
data = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
pid, uid, gid = struct.unpack("3i", data)
return pid, uid, gid
async def handle(reader, writer) -> None:
pid, uid, gid = peer_credentials(writer)
if uid not in ALLOWED_UIDS:
writer.close()
return
...
Tested: the credentials returned for a client in the same process matched its PID and UID. This gives a local daemon authentication without passwords or tokens — the approach used by systemd, PostgreSQL's peer authentication, and the Docker daemon. On macOS and BSD the equivalent is LOCAL_PEERCRED / getpeereid. The credentials are captured at connect time; a process that changes user later is still identified by the original.
Verify: log (pid, uid) for each connection and compare with ps for the client process.
5. Know the limits¶
Unix sockets are local only, and a few behaviours differ from TCP:
# Path length is limited (about 107 bytes on Linux, 103 on macOS)
assert len(SOCKET_PATH.encode()) < 100, "socket path too long"
# Abstract namespace (Linux only): no file at all, name starts with a NUL byte
server = await asyncio.start_unix_server(handle, path="\0myapp-api")
The path limit catches deep directories in tests and CI; keep socket paths short. Linux's abstract namespace avoids socket files entirely — nothing to clean up and no stale files — but has no filesystem permissions, so any process in the same network namespace can connect; rely on SO_PEERCRED there. Containers can share a Unix socket through a mounted volume, which is a common way to reach a sidecar without exposing a port. When the two processes may one day run on different hosts, keep the protocol transport-agnostic so moving to TCP is a configuration change.
Verify: the configured path passes the length check on every platform you deploy to.
Verification¶
Unix sockets are set up well when:
- The socket lives in a dedicated directory, with explicit permissions set at creation.
- Stale files are handled, and regular files are never deleted.
- Peer credentials identify clients where authentication matters.
- Paths stay short and the protocol stays transport-agnostic.
Diagnostic Hook: log connection counts with peer UIDs, and measure request latency on the socket against the same requests over TCP. Unexpected UIDs mean the permissions are looser than intended; no latency gain usually means the bottleneck is processing, not transport.
Pitfalls & edge cases¶
- Relying on the umask. Tested: the socket was created
0o775under the default umask. - Sockets in
/tmp. Other users can pre-create the path. - Long paths. Linux limits socket paths to about 107 bytes.
- Abstract sockets without credential checks. They have no file permissions at all.
Frequently Asked Questions¶
How do I use a Unix domain socket with asyncio?
Use asyncio.start_unix_server(handler, path=...) on the server and asyncio.open_unix_connection(path) on the client. The reader and writer work exactly like their TCP counterparts.
Are Unix sockets faster than localhost TCP in Python?
For latency, yes: a 64-byte round trip took 13.1 µs over a Unix socket against 39.0 µs over TCP loopback. Bulk throughput was similar because asyncio was the limit.
How do I restrict who can connect to a Unix socket?
Create it with a restrictive umask, such as 0o660, set its group to the allowed clients, and keep it in a directory with limited permissions. On Linux, check the peer's UID with SO_PEERCRED as well.
What happens to the socket file when the server stops?
From Python 3.13, server.close() removes it. A crashed server leaves a stale file, which start_unix_server replaces; it refuses to start over a regular file.
Related¶
- Streams, Transports & Protocols — up to the topic overview.
- Reducing copies with buffered protocols — when the transport is fast and Python becomes the bottleneck.
- Network I/O & Protocol Handling — the section overview.