Skip to content

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 with OSError: [Errno 98] Address already in use, because the previous connection's socket can still be lingering in the kernel's TIME_WAIT state — the same state ephemeral port exhaustion already covered from the client side. Python's own documentation for socket notes that SO_REUSEADDR lets the kernel reuse a local address that's still finishing its TIME_WAIT teardown, which is exactly the situation a server restarted seconds after being stopped runs into.
  • bind((HOST, PORT)) claims 127.0.0.1:65432 specifically — 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 about 0.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, and addr, 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 call close() 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: break is 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 empty data is 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 plain send(), 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:

python3 server.py
Listening on 127.0.0.1:65432

Then run the client in a second terminal:

python3 client.py
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:

Connection from ('127.0.0.1', 58226)
Client closed the connection

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

  1. Wrap the server's accept() call in a while 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?
  2. Modify the client to send a message larger than 1024 bytes in one sendall() call, and add a print statement showing the length of whatever recv(1024) returns on the server side. Confirm for yourself that a single recv() call doesn't have to return everything that was sent at once.
  3. Remove the SO_REUSEADDR line, run the server, stop it with Ctrl-C, and immediately try to start it again. Explain the error using the TIME_WAIT behavior 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