Building a TCP Server and Client in Python

· 5 min read · Updated March 6, 2026 · intermediate
networking sockets tcp server

TCP socket programming is one of the foundational skills for any developer working with networked applications. Python’s standard library makes it straightforward to create both servers and clients that communicate over TCP. Whether you are building a chat application, a custom protocol, or just learning how networks work under the hood, understanding sockets gives you direct control over network communication.

The socket Module

Python’s built-in socket module provides a low-level interface to the BSD socket API. It supports TCP (stream sockets) and UDP (datagram sockets), along with IPv4 and IPv6 addressing. The module wraps the operating system’s native socket calls, so the concepts transfer directly to other languages and platforms.

The core workflow for TCP is: create a socket object, bind it to an address (server side), listen for connections, and then send and receive data through the connection.

import socket

# Create a TCP/IPv4 socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

AF_INET specifies IPv4, and SOCK_STREAM specifies TCP. These two constants are the ones you will use most often.

Building a TCP Server

A TCP server follows a predictable sequence: bind to an address, listen for incoming connections, accept a connection, exchange data, and close the connection.

import socket

HOST = "127.0.0.1"
PORT = 65432

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_sock:
    server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_sock.bind((HOST, PORT))
    server_sock.listen(5)
    print(f"Server listening on {HOST}:{PORT}")

    conn, addr = server_sock.accept()
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            print(f"Received: {data.decode()}")
            conn.sendall(data)  # Echo back

Key steps explained:

  • bind((host, port)) associates the socket with a specific network interface and port number. Use "0.0.0.0" to listen on all interfaces.
  • listen(backlog) marks the socket as a passive socket that will accept incoming connections. The backlog argument sets the maximum number of queued connections.
  • accept() blocks until a client connects. It returns a new socket object for the connection and the client’s address.
  • setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) allows the port to be reused immediately after the server stops, avoiding “Address already in use” errors during development.

Building a TCP Client

The client side is simpler. You create a socket, connect to the server, and exchange data.

import socket

HOST = "127.0.0.1"
PORT = 65432

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_sock:
    client_sock.connect((HOST, PORT))
    message = "Hello, server!"
    client_sock.sendall(message.encode())

    data = client_sock.recv(1024)
    print(f"Received from server: {data.decode()}")
  • connect((host, port)) initiates the TCP three-way handshake with the server.
  • sendall(data) sends the entire byte string, handling partial sends internally. Always prefer sendall over send unless you need fine-grained control.
  • recv(bufsize) reads up to bufsize bytes from the connection. It returns an empty bytes object when the remote side has closed the connection.

Handling Multiple Clients

The basic server above handles only one client at a time. For concurrent connections, you can use threading or the selectors module.

Using Threading

import socket
import threading

HOST = "127.0.0.1"
PORT = 65432

def handle_client(conn, addr):
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)
    print(f"Disconnected: {addr}")

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_sock:
    server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_sock.bind((HOST, PORT))
    server_sock.listen(5)
    print(f"Server listening on {HOST}:{PORT}")

    while True:
        conn, addr = server_sock.accept()
        thread = threading.Thread(target=handle_client, args=(conn, addr))
        thread.daemon = True
        thread.start()

Each incoming connection is handed off to a new thread, allowing the main loop to continue accepting connections. Setting daemon = True ensures threads do not prevent the program from exiting.

Using selectors

For higher concurrency without the overhead of threads, the selectors module provides an event-driven approach built on top of select, poll, or epoll depending on the platform.

import socket
import selectors

sel = selectors.DefaultSelector()

def accept(server_sock):
    conn, addr = server_sock.accept()
    conn.setblocking(False)
    sel.register(conn, selectors.EVENT_READ, data=addr)

def read(conn, addr):
    data = conn.recv(1024)
    if data:
        conn.sendall(data)
    else:
        sel.unregister(conn)
        conn.close()

HOST, PORT = "127.0.0.1", 65432
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)
server_sock.setblocking(False)
sel.register(server_sock, selectors.EVENT_READ, data=None)

while True:
    events = sel.select(timeout=None)
    for key, mask in events:
        if key.data is None:
            accept(key.fileobj)
        else:
            read(key.fileobj, key.data)

Error Handling

Socket operations can fail in several ways. Wrapping calls in try/except blocks makes your code resilient.

import socket

try:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect(("192.0.2.1", 65432))
        s.sendall(b"ping")
        data = s.recv(1024)
except socket.timeout:
    print("Connection timed out")
except ConnectionRefusedError:
    print("Server is not running")
except OSError as e:
    print(f"Socket error: {e}")

Common exceptions to handle:

  • socket.timeout — raised when a socket operation exceeds the timeout set by settimeout().
  • ConnectionRefusedError — the server is not listening on the target address.
  • ConnectionResetError — the remote side unexpectedly closed the connection.
  • OSError — a catch-all for lower-level socket errors, such as “Address already in use.”

When to Use Raw Sockets vs Higher-Level Libraries

The socket module is ideal when you need full control over the protocol or are implementing a custom binary protocol. However, for many common tasks, higher-level alternatives save significant effort:

  • asyncio — provides async TCP servers and clients with asyncio.start_server() and asyncio.open_connection(), well suited for high-concurrency applications.
  • http.server — a quick way to serve HTTP without external dependencies.
  • socketserver — part of the standard library, it abstracts the boilerplate of threaded and forking servers.
  • aiohttp, Flask, FastAPI — full-featured web frameworks when you need HTTP/WebSocket support.

Start with raw sockets to understand the fundamentals, then move to these libraries when building production systems. The concepts of binding, listening, connecting, and managing data streams remain the same regardless of the abstraction layer.