TCP echo server with Python
The previous article named the four calls this program uses without showing them working together: socket(), bind(), listen(), accept() on the server side, and connect() on the client's. This article puts all of them into two complete, runnable files — a server and a client — and explains every meaningful line in the order it actually executes.
The server
import socket
HOST = "127.0.0.1"
PORT = 65432
def main():
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((HOST, PORT))
server_sock.listen(5)
print(f"Listening on {HOST}:{PORT}")
conn, addr = server_sock.accept()
print(f"Connection from {addr}")
with conn:
while True:
data = conn.recv(1024)
if not data:
print("Client closed the connection")
break
conn.sendall(data)
server_sock.close()
if __name__ == "__main__":
main()
Walking through it in execution order:
socket.socket(socket.AF_INET, socket.SOCK_STREAM)creates the listening socket — IPv4 addresses, TCP's reliable byte stream — exactly as the previous article introduced both constants.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)is here to solve a specific annoyance during development, not something a first version of this program strictly needs. Without it, restarting the server immediately after stopping it often fails withOSError: [Errno 98] Address already in use, because the previous connection's socket can still be lingering in the kernel'sTIME_WAITstate — the same state ephemeral port exhaustion already covered from the client side. Python's own documentation forsocketnotes thatSO_REUSEADDRlets the kernel reuse a local address that's still finishing itsTIME_WAITteardown, which is exactly the situation a server restarted seconds after being stopped runs into.bind((HOST, PORT))claims127.0.0.1:65432specifically — loopback only, on purpose, so this example never risks being reachable from anywhere but the same machine, in line with the binding warning the previous article raised about0.0.0.0.listen(5)marks the socket as one that accepts incoming connections and sets its backlog to 5 — deliberately small, since this toy server only ever handles one connection at a time and five pending connections is already more slack than it needs.accept()blocks here — execution genuinely stops on this line — until a client's connection completes the full three-way handshake and lands in the accept queue, exactly as the previous article described. It returns two things:conn, a brand-new socket for this one connection, andaddr, the client's(ip, port)pair.with conn:guarantees the connection socket gets closed when the block exits, whether that's because the loop below ends normally or because an exception is raised — the same context-manager pattern Python uses for files, applied here to a socket for the identical reason: nothing has to remember to callclose()manually.conn.recv(1024)reads up to 1024 bytes the client has sent, blocking if none have arrived yet. The 1024 is a buffer size ceiling, not a promise —recv()can return fewer bytes than that even if the client sent more, which the next article in this course, on UDP, contrasts directly against a very different framing behavior.if not data: breakis the loop's exit condition, and it depends on a detail worth stating plainly:recv()returning an empty bytes object (b'') specifically means the peer has closed its side of the connection — not that nothing arrived yet, which would instead simply block. An emptydatais TCP's way of surfacing the client's connection teardown, the same FIN-based close this course already covered at the packet level, to application code that never has to parse a FIN flag directly.conn.sendall(data)writes the identical bytes straight back.sendall(), rather than plainsend(), matters here for a reason that's easy to overlook:send()is permitted to write fewer bytes than asked and return how many it actually sent, leaving the rest for the caller to resend —sendall()handles that looping internally and only returns once every byte has actually been handed to the kernel, or raises an exception if it can't.
The client
import socket
HOST = "127.0.0.1"
PORT = 65432
def main():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_sock:
client_sock.connect((HOST, PORT))
for message in [b"first message", b"second message", b"third message"]:
client_sock.sendall(message)
data = client_sock.recv(1024)
print(f"Sent: {message!r}")
print(f"Received: {data!r}")
if __name__ == "__main__":
main()
The client is simpler because it never calls bind() or listen() — as the previous article explained, a client socket only calls connect(), and the kernel assigns its source port automatically. connect() here is the call that actually drives the three-way handshake to completion; by the time it returns, the connection is fully established and sendall()/recv() behave exactly as they do on the server's side of the same connection. The with statement around the socket closes it automatically once the for loop finishes, which sends a FIN to the server — exactly the event the server's if not data: break is waiting to see.
Running it and reading the output
Start the server first, in one terminal:
Then run the client in a second terminal:
Sent: b'first message'
Received: b'first message'
Sent: b'second message'
Received: b'second message'
Sent: b'third message'
Received: b'third message'
Back in the server's terminal, two more lines appear once the client connects and then exits:
The port number in that second line — 58226 here — is the ephemeral port the kernel picked for the client's side of the connection, and it will be a different number on every run; nothing about that varying is a problem to chase down.
What this simple version doesn't handle
This server accepts exactly one connection and then the program ends — a second client trying to connect while the first is still talking would sit in the accept queue, unanswered, since accept() was only ever called once. A real server calls accept() in a loop, handling each new connection independently, typically on its own thread or in its own coroutine — the next article's UDP comparison and the client-server model article after it both return to exactly this limitation and what fixes it.
Practice exercises
- Wrap the server's
accept()call in awhile True:loop so it can handle a second client after the first one disconnects, without restarting the program. What has to move inside the loop, and what should stay outside it? - Modify the client to send a message larger than 1024 bytes in one
sendall()call, and add a print statement showing the length of whateverrecv(1024)returns on the server side. Confirm for yourself that a singlerecv()call doesn't have to return everything that was sent at once. - Remove the
SO_REUSEADDRline, run the server, stop it with Ctrl-C, and immediately try to start it again. Explain the error using theTIME_WAITbehavior referenced above.
This server and client both assume TCP's stream: no message boundaries, no guarantee that one send() on one side lines up with one recv() on the other, just a continuous stream of bytes. UDP works from the opposite assumption entirely, and the next article shows exactly what changes in the code because of it.
Sources
- Python Software Foundation, socket — Low-level networking interface