Tutorial: Learn Python Networking from Scratch (2026)
I wrote my first TCP chat server in Python during college, and watching two terminals talk to each other felt like magic. Python's socket module provides a low-level interface to Berkeley sockets, giving you direct control over network communication. While high-level protocols like HTTP dominate modern development, understanding sockets and the fundamentals of TCP/IP is essential for debugging, performance tuning, and working with custom protocols.
This tutorial covers the networking layers from sockets up to async HTTP servers. We'll build a TCP echo server and client, implement a simple custom protocol, use async IO for concurrent connections, and finish with a basic HTTP server in the standard library. By the end you'll understand what happens when you call requests.get() under the hood.
TCP Sockets: Server and Client Basics
A socket is an endpoint for network communication. The server creates a socket, binds to an address and port, listens for connections, and accepts them in a loop. Each accepted connection returns a new socket object for that client. The client creates a socket and connects to the server's address. Data is sent and received as bytes using send() and recv(). TCP guarantees ordering and delivery, but you must handle framing yourself.
import socket
# Server
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8080))
server.listen(5)
print("Server listening on port 8080")
while True:
client, addr = server.accept()
print(f"Connected from {addr}")
data = client.recv(1024)
client.send(b"Echo: " + data)
client.close()
# Client
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 8080))
client.send(b"Hello, server!")
response = client.recv(1024)
print(response.decode())
client.close()
UDP Sockets: Connectionless Communication
UDP sockets use SOCK_DGRAM instead of SOCK_STREAM. There's no connection — you send datagrams directly to an address with sendto() and receive with recvfrom(), which returns both the data and the sender's address. UDP is faster but unreliable: packets can be lost, duplicated, or arrive out of order. Use UDP for real-time applications like video streaming or DNS lookups where speed matters more than perfect delivery.
# UDP Server
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(('0.0.0.0', 9090))
data, addr = server.recvfrom(1024)
print(f"Received from {addr}: {data}")
server.sendto(b"ACK", addr)
# UDP Client
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.sendto(b"Hello via UDP", ('127.0.0.1', 9090))
data, server_addr = client.recvfrom(1024)
print(data.decode())
Handling Multiple Connections with selectors
The selectors module provides I/O multiplexing, letting a single thread monitor multiple sockets for readability or writability. When any socket becomes ready, the selector returns it. This is the same mechanism as select(), poll(), and epoll() but with a unified interface. It's more efficient than threading for many concurrent connections because there's no context switching overhead.
import selectors
import socket
sel = selectors.DefaultSelector()
server = socket.socket()
server.bind(('0.0.0.0', 8080))
server.listen(100)
server.setblocking(False)
sel.register(server, selectors.EVENT_READ, data=None)
def accept(sock):
conn, addr = sock.accept()
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, data=addr)
def read(conn):
data = conn.recv(1024)
if data:
conn.send(b"Echo: " + data)
else:
sel.unregister(conn)
conn.close()
while True:
for key, mask in sel.select():
if key.data is None:
accept(key.fileobj)
else:
read(key.fileobj)
Custom Protocol Design and Framing
TCP provides a byte stream with no message boundaries — if you send two messages, the receiver might get them merged in a single recv(). The solution is framing: define a message format that includes a length prefix or delimiter. A common approach is a fixed-size header containing the payload length, followed by the payload. This lets you reconstruct complete messages on the receiving end.
import struct
def send_msg(sock, msg_bytes):
# Prefix each message with a 4-byte length (network order)
length = len(msg_bytes)
sock.sendall(struct.pack('!I', length) + msg_bytes)
def recv_msg(sock):
# Read the 4-byte length
raw_len = recv_exact(sock, 4)
if not raw_len:
return None
length = struct.unpack('!I', raw_len)[0]
# Read the payload
return recv_exact(sock, length)
def recv_exact(sock, n):
buf = b''
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
Async IO with asyncio for Network Servers
Asyncio provides high-level primitives for network servers: asyncio.start_server() creates a TCP server, and asyncio.open_connection() connects to one. The server handler is a coroutine that receives reader and writer streams. This approach handles thousands of concurrent connections with minimal overhead because the event loop switches between tasks only when they await I/O.
import asyncio
async def handle_client(reader, writer):
addr = writer.get_extra_info('peername')
print(f"Connected: {addr}")
while True:
data = await reader.read(1024)
if not data:
break
writer.write(b"Echo: " + data)
await writer.drain()
writer.close()
await writer.wait_closed()
async def main():
server = await asyncio.start_server(handle_client, '0.0.0.0', 8888)
async with server:
await server.serve_forever()
asyncio.run(main())
Building an HTTP Server with http.server
Python's http.server module implements a basic HTTP server using sockets underneath. Subclass BaseHTTPRequestHandler and override do_GET, do_POST, etc. to handle different methods. The handler parses request headers, query parameters, and body. While not suitable for production (single-threaded), it's invaluable for testing, prototyping, and understanding HTTP protocol mechanics.
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
class APIHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
response = {"message": "Hello", "path": self.path}
self.wfile.write(json.dumps(response).encode())
def do_POST(self):
length = int(self.headers['Content-Length'])
body = self.rfile.read(length)
data = json.loads(body)
self.send_response(201)
self.end_headers()
self.wfile.write(json.dumps({"received": data}).encode())
server = HTTPServer(('0.0.0.0', 8000), APIHandler)
print("Server running on port 8000")
server.serve_forever()
Frequently Asked Questions
What is the difference between TCP and UDP?
TCP is connection-oriented, guaranteed delivery, ordered, but slower. UDP is connectionless, no guarantee, no ordering, but faster. Choose TCP for reliability (web, email, file transfer). Choose UDP for speed (streaming, gaming, DNS).
How do I handle partial recv() calls?
TCP doesn't preserve message boundaries. Always loop on recv() until you have the expected number of bytes. Use length-prefixing or delimiters to frame your protocol. See the recv_exact pattern above.
What is non-blocking I/O?
Non-blocking sockets return immediately from recv()/send() with whatever data is available (or an error if nothing). Combined with selectors or asyncio, this lets a single thread manage many connections efficiently.
Should I use raw sockets or an HTTP library?
Use raw sockets when building custom protocols, game servers, or learning fundamentals. Use requests/aiohttp/httpx for HTTP in production — they handle connection pooling, retries, timeouts, and SSL correctly.
Originally published on Ayodhyyya. Last updated June 1, 2026.