Networking Basics
Sockets are the low-level endpoint abstraction underneath every network connection — understanding TCP versus UDP and the client/server model is the foundation for everything built on top.
Before reaching for a high-level networking library, it helps to understand the primitives underneath: sockets, the TCP/UDP protocol distinction, and the client/server model. Python's standard-library socket module exposes these primitives directly, using blocking calls by default. This topic covers the concepts and a minimal working example; it does not cover asyncio-based or non-blocking networking, which build on these same fundamentals.
What Is a Socket
A socket is an endpoint for network communication -- an abstraction the operating system provides that lets a program send and receive data over a network, identified by an address (an IP address) and a port number. Creating a socket is conceptually like picking up a telephone handset: it doesn't establish a conversation by itself, but it's the object through which a conversation happens.
import socket
# AF_INET: IPv4 addressing; SOCK_STREAM: TCP (a connection-oriented byte stream)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print(s)
s.close()TCP Versus UDP
TCP (Transmission Control Protocol) is connection-oriented: two endpoints establish a connection first, then exchange a reliable, ordered stream of bytes -- the protocol guarantees delivery and order (retransmitting lost packets automatically) at the cost of some overhead and latency. UDP (User Datagram Protocol) is connectionless: each message ("datagram") is sent independently with no guarantee of delivery, ordering, or duplicate-avoidance, but with lower overhead and latency.
Choosing between them depends on whether the application needs guaranteed, ordered delivery (use TCP) or can tolerate occasional loss/reordering in exchange for lower latency and simplicity (UDP is often used for real-time media and simple query/response protocols like DNS).
TCP (SOCK_STREAM) UDP (SOCK_DGRAM)
------------------ -----------------
connection-oriented connectionless
reliable, ordered delivery no delivery/order guarantee
higher overhead, handshake lower overhead, no handshake
web (HTTP), email, file transfer DNS lookups, video/voice streaming,
game state updatesThe Client/Server Model
Most network applications follow a client/server model: a *server* listens on a known address and port, waiting for connections; a *client* initiates a connection to that address and port. The server is typically long-running and handles requests from potentially many clients; the client typically connects, does its work, and disconnects.
A TCP server's typical socket lifecycle: create a socket, bind() it to an address/port, listen() for incoming connection attempts, accept() each incoming connection (which returns a *new* socket dedicated to that one client), then send/receive data over that per-client socket. A TCP client's lifecycle: create a socket, connect() to the server's address/port, then send/receive data over that same socket.
Blocking Sockets
By default, Python sockets are *blocking*: a call like .accept(), .recv(), or .connect() pauses the calling thread until the operation completes (a connection arrives, data is received, or the connection succeeds) -- the program does nothing else while waiting. This is simple to reason about but means a single-threaded blocking server can only usefully handle one client connection at a time unless it uses threading, multiprocessing, or switches to non-blocking/asynchronous I/O (select, asyncio) to handle many clients concurrently.
Non-blocking sockets (.setblocking(False)) and asynchronous networking (the asyncio module, or the selectors module) are separate, more advanced topics built on these same socket fundamentals -- out of scope here, but worth knowing they exist once a blocking, one-client-at-a-time model becomes a limitation.
Creating a Basic TCP Server
A minimal TCP server: bind a socket to an address and port, listen for connections, and accept and handle one client at a time.
with blocks ensure sockets are closed even on error; recv() returning an empty bytes object is the standard signal that the peer has closed its side of the connection.
import socket
def run_echo_server(host: str = "127.0.0.1", port: int = 5000) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((host, port))
server_socket.listen(backlog=1)
print(f"listening on {host}:{port}")
conn, addr = server_socket.accept() # blocks until a client connects
with conn:
print("connected by", addr)
while True:
data = conn.recv(1024) # blocks until data arrives (or connection closes)
if not data:
break # empty bytes means the client closed the connection
conn.sendall(data) # echo it back
# run_echo_server() # commented out -- would block waiting for a real clientCreating a Basic TCP Client
The corresponding client: create a socket, connect to the server's address and port, send data, and receive the response.
sendall() ensures the entire message is sent (send() alone can send only part of the data on a busy connection); text must be encoded to bytes before sending and decoded back after receiving.
import socket
def run_echo_client(message: str, host: str = "127.0.0.1", port: int = 5000) -> str:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
client_socket.connect((host, port))
client_socket.sendall(message.encode("utf-8"))
response = client_socket.recv(1024)
return response.decode("utf-8")
# reply = run_echo_client("hello, server") # requires the server to be running
# print(reply)Basic Networking Terminology
A few terms worth being precise about: an *IP address* identifies a host (machine) on a network; a *port* (0-65535) identifies a specific service or connection endpoint on that host, letting many programs on one machine use the network simultaneously; a *socket address* is the (host, port) pair together. "127.0.0.1" (or "localhost") is the loopback address, referring to the machine the code is running on itself -- useful for local testing without any real network involved.
IP address identifies a HOST, e.g. 192.168.1.10
port identifies a SERVICE/endpoint on that host, 0-65535
(host, port) a full socket address, e.g. ("127.0.0.1", 5000)
127.0.0.1/localhost the loopback address -- "this same machine"
well-known ports 0-1023, reserved for standard services (80=HTTP, 443=HTTPS)
bind() associate a socket with a local address/port
listen() mark a socket as ready to accept incoming connections
accept() block until a client connects; returns a new per-client socket
connect() initiate a connection to a remote address/port
send/recv exchange bytes over an established connectionPractical Usage
Raw socket programming is the foundation, but most application-level networking in practice uses a higher-level layer built on top of it: http.client/the third-party requests library for HTTP clients, http.server for simple HTTP servers, or full frameworks (Flask, FastAPI -- third-party) for web applications. Reaching for raw sockets directly is appropriate for custom protocols, low-level tooling, learning how networking works, or performance-sensitive cases where an existing protocol library doesn't fit.
Edge Cases
recv(bufsize) may return fewer bytes than requested even if more are coming -- TCP delivers a byte stream, not message boundaries, so a single sendall() call on one side is not guaranteed to correspond to a single recv() call's worth of bytes on the other; applications that need discrete messages must implement their own framing (e.g. a length prefix, or a delimiter).
A connect() call can fail (e.g. ConnectionRefusedError if nothing is listening, or a timeout) -- always be prepared to handle these exceptions rather than assuming a connection attempt succeeds.
Forgetting to close a socket (or not using a with block / try-finally) can leak OS resources, especially in a long-running server that creates many per-client sockets.
socket.SO_REUSEADDR (used above) avoids an "address already in use" error when quickly restarting a server that was recently using the same port -- without it, the OS may hold the port briefly after the previous socket closed.
Best Practices
Always use sockets as context managers (with socket.socket(...) as s:) so they're closed reliably even on exceptions. Handle connection-related exceptions explicitly rather than letting them crash a server. Use sendall() instead of send() for reliably sending an entire payload. Design an explicit message-framing scheme (length-prefixing or a delimiter) whenever your application needs distinct messages, rather than assuming recv() calls align with sendall() calls.
Complete Example
Handling each client in its own thread is a common way to move a simple blocking-socket server beyond one-client-at-a-time, without switching to non-blocking I/O.
import socket
import threading
def handle_client(conn: socket.socket, addr) -> None:
with conn:
print("connected by", addr)
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(data.upper()) # simple "uppercase echo" service
def run_server(host: str = "127.0.0.1", port: int = 5001) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((host, port))
server_socket.listen()
print(f"listening on {host}:{port}")
while True:
conn, addr = server_socket.accept()
# one thread per client so multiple clients can connect concurrently
threading.Thread(target=handle_client, args=(conn, addr), daemon=True).start()
# run_server() # would block forever, serving connectionsQuick Reference
socket.socket(AF_INET, SOCK_STREAM) TCP socket (connection-oriented)
socket.socket(AF_INET, SOCK_DGRAM) UDP socket (connectionless)
.bind((host, port)) associate socket with local address
.listen() mark ready to accept connections (server)
.accept() block for a client; returns (conn, addr)
.connect((host, port)) initiate a connection (client)
.sendall(data) send all bytes, retrying as needed
.recv(bufsize) receive up to bufsize bytes (blocking)
b"" from recv() peer closed the connection
127.0.0.1 / "localhost" loopback address, this machineKey Takeaways
A socket is an OS-provided endpoint for network communication, identified by an (address, port) pair.
TCP is connection-oriented and reliable/ordered; UDP is connectionless with no delivery guarantees but lower overhead -- choose based on whether the application needs guaranteed ordered delivery.
The client/server model has a server bind/listen/accept and a client connect; accept() returns a new socket dedicated to each connected client.
Sockets are blocking by default -- calls like accept()/recv()/connect() pause the calling thread until they complete.
TCP delivers a byte stream with no built-in message boundaries -- applications needing discrete messages must implement their own framing.
Use sockets as context managers, handle connection exceptions explicitly, and prefer sendall() over send() for reliably sending a full payload.
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((host, port)) #... use s... # forgot to close s with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect((host, port)) #... use s...
Using the socket as a context manager guarantees it's closed even if an exception occurs, avoiding leaked OS resources.
client_socket.send(large_payload) # may send only part of the data send() can transmit fewer bytes than given on a single call and returns the count sent; sendall() loops internally to guarantee the entire payload is sent (or raises on error).
data = conn.recv(1024) message = json.loads(data) # assumes one recv() == one complete message
TCP has no message boundaries; a single recv() call may return a partial message, multiple messages, or anything in between -- application-level framing is required to reconstruct discrete messages reliably.
What is a socket, conceptually?
An operating-system-provided endpoint for network communication, identified by an address and port, through which a program sends and receives data.
What is the key behavioral difference between TCP and UDP?
TCP is connection-oriented and guarantees reliable, ordered delivery (retransmitting lost data); UDP is connectionless, sending independent datagrams with no guarantee of delivery, order, or deduplication, in exchange for lower overhead.
In the client/server model, what does accept() return, and why is that important?
It returns a new socket object dedicated to the newly connected client (plus that client's address), separate from the original listening socket -- the server keeps listening for further connections on the original socket while using the new one to talk to this specific client.
What does it mean for a socket to be 'blocking', and what is a consequence of that default?
A blocking call like accept()/recv()/connect() pauses the calling thread until it completes; a consequence is that a simple single-threaded blocking server can only handle one client's I/O at a time unless it adds threading, multiprocessing, or switches to non-blocking/async I/O.
Why can't you assume that one sendall() call on the client corresponds to exactly one recv() call's worth of data on the server?
TCP delivers an ordered byte stream with no built-in message boundaries -- the underlying network can split or coalesce the data arbitrarily, so recv() may return partial data, multiple sent messages combined, or anything in between; the application must implement its own framing if it needs discrete messages.
What does an empty bytes object (b'') returned from recv() signal?
That the peer has closed its side of the connection -- it's the standard TCP end-of-stream signal, distinct from recv() returning some bytes of actual data.
Why is '127.0.0.1' (or 'localhost') useful for testing networking code?
It's the loopback address, always referring to the machine the code itself is running on, so a client and server can be tested against each other on one machine without any real external network involved.
Write a function send_message(sock, text) that sends a UTF-8 encoded string over an already-connected TCP socket, using sendall to guarantee full delivery.
import socket def send_message(sock: socket.socket, text: str) -> None: payload = text.encode("utf-8") sock.sendall(payload) # Example usage (requires a connected socket 'sock'): # send_message(sock, "hello from the client")
The function encodes the string to bytes and uses sendall() (not send()) so the entire payload is guaranteed to be transmitted before the call returns, even if the underlying connection can only accept part of it at a time.
What does this print, and what is the practical difference between these two constants?
False. SOCK_DGRAM creates a UDP (connectionless, unreliable) socket while SOCK_STREAM creates a TCP (connection-oriented, reliable/ordered) socket -- they are different constants selecting fundamentally different transport protocols.
Write a function recv_all(sock, num_bytes) that reliably receives exactly num_bytes from a TCP socket, looping to handle partial recv() calls, and raising ConnectionError if the peer closes early.
import socket def recv_all(sock: socket.socket, num_bytes: int) -> bytes: chunks = [] remaining = num_bytes while remaining > 0: chunk = sock.recv(remaining) if not chunk: raise ConnectionError("peer closed connection before sending all data") chunks.append(chunk) remaining -= len(chunk) return b"".join(chunks) # Example usage (requires a connected socket 'sock'): # data = recv_all(sock, 1024)
The function loops, accumulating bytes until exactly num_bytes have been received, because a single recv() call is not guaranteed to return that many bytes at once; an empty chunk mid-loop means the peer closed the connection early, which is treated as an error via the raised ConnectionError.
import socket
sock_type = socket.SOCK_DGRAM
print(sock_type == socket.SOCK_STREAM)