Every port scanner, banner grabber, SSH client and packet reader in this course begins with the same few lines of socket code. Learn them once, properly, and you will recognise them everywhere. This article builds both halves of a TCP conversation and both halves of a UDP one, and then shows you your own handshake on the wire.
A socket is the operating system's handle on one end of a network connection: an address, a port and a protocol, bundled into one object your program can read from and write to. If the office building from the previous article is a host with numbered doors, a socket is the telephone handset wired to one specific door.
Tool in focus: The socket module
socket is part of Python's standard library and needs no installation. It is the single most important module in this course: the port scanner, the banner grabber, the SSH client's transport, and the packet-capture reader all begin with a socket.
socket.AF_INET selects IPv4 addressing. socket.SOCK_STREAM selects TCP, and socket.SOCK_DGRAM selects UDP.
A TCP client and server
Analogy: The telephone call
A TCP connection is a telephone call. The server is the person who leaves their phone on the hook, waiting; that is listen(). The client dials; that is connect(). The server picks up; that is accept(). Both then talk, send() and recv(), and finally one side hangs up; that is close(). The figure lines up the two sequences of calls side by side.
The client half
Here is the client. It connects to a web server, sends a minimal HTTP request, and reads the reply.
tcp_client.py
# tcp_client.py: connect to a service and read its response
import socket
HOST = "10.0.0.5"
PORT = 80
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
sock.sendall(b"GET / HTTP/1.1\r\nHost: " + HOST.encode() + b"\r\n\r\n")
response = sock.recv(4096)
print(response.decode(errors="replace"))
sock.close()
| Line | What it does |
|---|---|
socket.socket(AF_INET, SOCK_STREAM) | Picks up a fresh handset: IPv4, TCP |
sock.connect((HOST, PORT)) | Dials. The tuple holds the address and the door. This is where the three-way handshake happens |
b"GET / HTTP/1.1\r\n..." | The request, as bytes. The b prefix makes it bytes, not text |
HOST.encode() | Turns the text host name into bytes so it can join the request |
sock.sendall(...) | Sends every byte, retrying until all have gone |
sock.recv(4096) | Reads up to 4096 bytes of the reply, not necessarily all of it |
response.decode(errors="replace") | Turns the reply bytes back into text, replacing anything unreadable instead of crashing |
sock.close() | Hangs up |
Two details trip up beginners. First, data sent over a socket must be bytes, not a plain string, which is why the request carries a b prefix and the host name is turned into bytes with .encode(). Second, .recv(4096) reads at most 4096 bytes at once, not necessarily the whole reply, which is why real clients read in a loop until nothing more arrives.
Reading the whole reply
That loop looks like this. An empty read means the other side has finished and hung up. Here it is, fetching a page from a throwaway web server on your own machine (start one with python3 -m http.server 9998 --bind 127.0.0.1 in another terminal):
recv_all.py
# recv_all.py: read until the other side closes the connection
import socket
def fetch(host, port, path="/"):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
request = f"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"
sock.sendall(request.encode())
chunks = []
while True:
chunk = sock.recv(4096)
if not chunk: # an empty read means the server hung up
break
chunks.append(chunk)
sock.close()
return b"".join(chunks)
response = fetch("127.0.0.1", 9998)
head, _, body = response.partition(b"\r\n\r\n")
print(head.decode(errors="replace"))
print(f"[{len(body)} bytes of body received]")
Sample output
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.14.8
Date: Sat, 17 Oct 2026 13:23:15 GMT
Content-type: text/html; charset=utf-8
Content-Length: 599
[599 bytes of body received]
Look at the second line of the reply: Server: SimpleHTTP/0.6 Python/3.14.8. The server just announced its own software and version to anyone who asked. Hold that thought. It is the subject of the banner-grabbing article.
The server half
The server half waits for that client:
tcp_server.py
# tcp_server.py: a minimal echo server for lab testing
import socket
HOST = "0.0.0.0" # listen on every network interface
PORT = 9999
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen(5)
print(f"Listening on {HOST}:{PORT}")
while True:
client_sock, client_addr = server.accept()
print(f"Connection from {client_addr}")
data = client_sock.recv(1024)
client_sock.sendall(data) # echo it straight back
client_sock.close()
.bind() attaches the socket to a local address and port; .listen(5) tells the operating system to hold up to five pending callers in a queue; and .accept() blocks until a client connects, returning a fresh socket for that one conversation plus the caller's address.
Python concept: Two sockets on the server
The server socket is the phone on the hook: its only job is to wait. When a call arrives, accept() hands back a new socket, client_sock, wired to that one caller. The conversation happens on client_sock; the original keeps waiting for the next call. That is why the loop can go round again and serve another client.
Python concept: What SO_REUSEADDR is for
Stop the server and start it again straight away, and the operating system may still be holding the old connection in a short "waiting" state. Without SO_REUSEADDR, the second start fails with an "address already in use" error. Setting the option lets the restart succeed. (On Windows the option behaves differently, which is fine for a lab; just do not copy it into a real service without reading about SO_EXCLUSIVEADDRUSE.)
Caution: Listening on 0.0.0.0 opens the door to the whole network
0.0.0.0 means "every network interface", so anyone who can reach your machine can reach this server. For a lab that is the point. For your own quick experiments, prefer 127.0.0.1, which only your own computer can reach. Never leave a test server running on a machine connected to a real network.
Run both halves together
Run the server in one terminal. In a second terminal, run a client pointed at your own machine. This version sends a short custom message instead of an HTTP request, which is what the lab exercise asks for:
echo_client.py
# echo_client.py: send a short custom message to the echo server
import socket
HOST = "127.0.0.1"
PORT = 9999
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
sock.sendall(b"hello from the lab")
reply = sock.recv(1024)
print(f"Server echoed: {reply.decode()}")
sock.close()
Client terminal
Server echoed: hello from the lab
Server terminal
Listening on 0.0.0.0:9999
Connection from ('127.0.0.1', 60832)
The number after the address, 60832 here, is the port your client was given by the operating system for this one conversation, from the dynamic range you met in the networking article. Run the client again and it will be different.
Try it yourself: See the handshake with your own eyes
Start the echo server, then in a third terminal run sudo tcpdump -i lo port 9999 before you launch the client. When the client connects, tcpdump prints three lines in quick succession marked [S], [S.] and [.]. Those are the SYN, SYN-ACK and ACK of the three-way handshake, captured live.
IP 127.0.0.1.60832 > 127.0.0.1.9999: Flags [S], seq 2087350106, win 65495, ...
IP 127.0.0.1.9999 > 127.0.0.1.60832: Flags [S.], seq 1409123407, ack 2087350107, ...
IP 127.0.0.1.60832 > 127.0.0.1.9999: Flags [.], ack 1, ...
The numbers will differ on your machine; the three flags will not. You have now seen, on your own computer, the three-step greeting every TCP connection in this course begins with. (On Windows or macOS the capture tools differ, but the three steps are the same.)
A UDP client and server
UDP needs neither listen() nor connect(), because there is no standing call to set up. Every send names its own destination, and every receive reports where the packet came from.
udp_server.py
# udp_server.py
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(("0.0.0.0", 9997))
print("UDP server listening on port 9997")
while True:
data, client_addr = server.recvfrom(1024)
print(f"Received {data} from {client_addr}")
server.sendto(b"pong", client_addr)
udp_client.py
# udp_client.py
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(b"ping", ("127.0.0.1", 9997))
data, server_addr = sock.recvfrom(1024)
print(f"Reply from {server_addr}: {data}")
Server terminal, then client terminal
UDP server listening on port 9997
Received b'ping' from ('127.0.0.1', 51208)
Reply from ('127.0.0.1', 9997): b'pong'
The only structural change from TCP is SOCK_DGRAM in place of SOCK_STREAM, and the use of sendto and recvfrom, which carry the address with every datagram because UDP keeps no connection to remember it by.
When the door is already taken
Start the echo server twice and the second one cannot claim port 9999. Python raises an OSError that begins [Errno 98] Address already in use on Linux, or [WinError 10048] Only one usage of each socket address ... is normally permitted on Windows. One door, one service. When a server will not start, look for an earlier copy still running before you suspect your code.
Case study: Scenario: the honeypot that would not start
A student builds a small listener to see who knocks on her lab network. It works on Monday. On Tuesday it refuses to start with "address already in use". Nothing in her code changed; Monday's copy was still running in a forgotten terminal, holding the door. She closes it, and the new copy binds at once. A port can have only one owner at a time, and bind() is where that rule is enforced. This scenario is illustrative, but every network programmer has met it.
Check your understanding
Question 1: What are AF_INET, SOCK_STREAM and SOCK_DGRAM?
AF_INET selects IPv4 addressing. SOCK_STREAM selects TCP. SOCK_DGRAM selects UDP.
Question 2: Why does the server's accept() return a new socket?
accept() waits for a client to connect, then returns a fresh socket wired to that one caller plus the caller's address. The original server socket stays free to keep waiting for the next call, so the server can serve many clients one after another.
Question 3: Why do sendto and recvfrom carry an address when send and recv do not?
A TCP connection remembers who is on the other end, so send and recv need no address. UDP keeps no connection, so every datagram must name its destination (sendto) and every received datagram reports its sender (recvfrom).
Question 4: A client calls recv(4096) once and gets only part of a reply. Why, and what is the fix?
recv(4096) returns at most 4096 bytes and not necessarily the whole reply. The fix is to read in a loop and keep appending chunks until recv returns an empty result, which means the other side has closed the connection.
Key takeaway: Key takeaway
A socket is a handset wired to one door. The server runs socket, bind, listen and accept; the client runs socket and connect; both then send and recv bytes and close. UDP drops the call setup and carries an address on every datagram. Everything else in this course is built on these few calls.

