{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"i-built-an-http-server-from-scratch-in-python-to-learn-how-http-really-works-jkzah","url":"https://zyvop.com/i-built-an-http-server-from-scratch-in-python-to-learn-how-http-really-works-jkzah","title":"I built an HTTP server from scratch in Python to learn how HTTP really works","subtitle":"Build a working HTTP/1.1 server from scratch in Python using raw TCP streams, strict request parsing, routing, keep-alive connections, static files, caching, and security checks.","tldr":"I built a 470-line HTTP server using only Python's standard library to understand what really happens between a browser and a web framework—from parsing raw TCP bytes to handling keep-alive, caching, malformed requests, and path traversal.","keywords":["backend","Web Development","Python","Networking","http"],"entities":["Arpan Singh","backend","Web Development","Python","Networking","http","ZyVOP"],"keyTakeaways":["Web frameworks hide almost everything that happens on the wire.","You write a function, return some JSON, and it somehow reaches the browser.","I wanted to see the part in between, so I built an HTTP server using nothing but Python's standard library and plain TCP streams."],"headings":["What HTTP looks like on the wire","The plan","Step 1: Reading a request","Step 2: Writing a response","Step 3: Routing","Step 4: A few endpoints that teach","Step 5: Serving files safely","Step 6: The connection loop","Try it","Testing it","What I learned","What is missing"],"outboundLinks":[],"contentText":"Web frameworks hide almost everything that happens on the wire. You write a function, return some JSON, and it somehow reaches the browser. I wanted to see the part in between, so I built an HTTP server using nothing but Python's standard library and plain TCP streams. It is about 470 lines with no dependencies. It handles GET, HEAD and POST, routes requests to handler functions, serves static files with caching, keeps connections open between requests, and rejects malformed or dangerous input. It comes with 99 tests, and 12 of them use curl as the client, so the server is checked against software I did not write. This is a learning project. Please do not put it on the public internet. What HTTP looks like on the wire Before writing any code, look at what actually travels between a client and a server. HTTP/1.1 is plain text. This is a complete request: GET /hello?name=ada HTTP/1.1 Host: localhost User-Agent: demo/1.0 Connection: closeThe first line is the request line: a method (GET), a target (/hello?name=ada), and a protocol version. Then come headers, one per line, in Name: value form. A blank line ends the headers. Every line ends with a carriage return and a line feed (\\r\\n), which is why you cannot see the line endings in the text above. (I added Connection: close so the server hangs up when it is done, which makes the captured output easy to read.) The server answers in the same shape: HTTP/1.1 200 OK Date: Tue, 06 Oct 2026 04:58:04 GMT Server: tinyhttp/1.0 Content-Type: text/plain; charset=utf-8 Content-Length: 12 Connection: close Hello, ada!The first line is the status line: version, status code, and a human-readable reason. Then headers, a blank line, and the body. The Content-Length header says the body is exactly 12 bytes, which is how the client knows where the response ends. HTTP needs some way to mark the end of a message, and the options are Content-Length, chunked encoding, or closing the connection. This server uses Content-Length and closing. A request can have a body too. This POST sends 15 bytes of JSON, and the Content-Length header says so: POST /echo HTTP/1.1 Host: localhost Content-Type: application/json Content-Length: 15 Connection: close {\"name\": \"ada\"}HTTP/1.1 200 OK Date: Tue, 06 Oct 2026 04:58:04 GMT Server: tinyhttp/1.0 Content-Type: application/json Content-Length: 92 Connection: close { \"content_type\": \"application/json\", \"length\": 15, \"data\": { \"name\": \"ada\" } }That is almost everything HTTP/1.1 is. The work is in handling all the ways real clients, and bad clients, deviate from this picture. The plan The server has a few jobs: Accept TCP connections Read a request from the byte stream and check it carefully Find the right handler, or serve a file Write a response with correct headers Decide whether to keep the connection open for the next request It all lives in one file. Here are the imports and a few constants, including the limits that protect the server from oversized input: import argparse import asyncio import json import mimetypes import os import re import time import traceback from dataclasses import dataclass, field from email.utils import formatdate from http import HTTPStatus from urllib.parse import parse_qs, quote, unquoteSERVER_NAME = \"tinyhttp/1.0\" KNOWN_METHODS = {\"GET\", \"HEAD\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\", \"TRACE\", \"CONNECT\"} MAX_LINE = 8 * 1024 # the request line, or any single header line MAX_HEADERS = 100 MAX_BODY = 1024 * 1024 TOKEN = re.compile(r\"[!#$%&amp;'*+.^_`|~0-9A-Za-z-]+\") # characters allowed in a header nameStep 1: Reading a request TCP gives you a stream of bytes with no message boundaries. Two requests sent back to back can arrive in one chunk, and one request can arrive in ten. So the server cannot just \"receive a request\". It has to follow the rules: read the request line, read header lines until a blank line, then read exactly Content-Length bytes of body. First, two small types. HttpError carries a status code for requests we refuse, and Request holds what we parsed: class HttpError(Exception): \"\"\"Raised while reading a request that we cannot or will not handle.\"\"\" def __init__(self, status, message=None): super().__init__(message or HTTPStatus(status).phrase) self.status = status @dataclass class Request: method: str target: str # exactly what came after the method, e.g. /hello?name=ada version: str headers: dict # names are lowercased because header names are case-insensitive body: bytes = b\"\" @property def path(self): return unquote(self.target.partition(\"?\")[0]) @property def query(self): return parse_qs(self.target.partition(\"?\")[2]) def header(self, name, default=None): return self.headers.get(name.lower(), default)Header names are case-insensitive, so Request keeps them lowercased. The path and query properties decode percent-escapes like %20. Now the reading code, one line at a time: async def read_line(reader, too_long_status): \"\"\"Read one line without its line ending. Returns None if the client disconnected.\"\"\" try: line = await reader.readline() except ValueError: # longer than the stream's own buffer limit raise HttpError(too_long_status) if len(line) &gt; MAX_LINE: raise HttpError(too_long_status) if not line.endswith(b\"\\n\"): return None line = line[:-1] if line.endswith(b\"\\r\"): line = line[:-1] return line async def read_headers(reader): headers = {} count = 0 while True: line = await read_line(reader, 431) if line is None: return None if line == b\"\": # an empty line ends the headers return headers count += 1 if count &gt; MAX_HEADERS: raise HttpError(431, \"too many header fields\") name, colon, value = line.decode(\"latin-1\").partition(\":\") if not colon or not TOKEN.fullmatch(name): raise HttpError(400, \"malformed header line\") name = name.lower() value = value.strip(\" \\t\") if name == \"content-length\" and name in headers: if headers[name] != value: raise HttpError(400, \"conflicting Content-Length headers\") elif name in headers: headers[name] += \", \" + value # repeated headers are equivalent to one comma-joined header else: headers[name] = value async def read_request(reader, writer): \"\"\"Read one request. Returns None if the client disconnected before it was complete.\"\"\" line = await read_line(reader, 414) if line == b\"\": # be forgiving about one stray blank line before a request line = await read_line(reader, 414) if line is None: return None try: method, target, version = line.decode(\"ascii\").split(\" \") except (UnicodeDecodeError, ValueError): raise HttpError(400, \"malformed request line\") if not re.fullmatch(r\"[A-Z]+\", method) or not target.startswith(\"/\"): raise HttpError(400, \"malformed request line\") if not re.fullmatch(r\"HTTP/[0-9]\\.[0-9]\", version): raise HttpError(400, \"malformed HTTP version\") if version not in (\"HTTP/1.0\", \"HTTP/1.1\"): raise HttpError(505) headers = await read_headers(reader) if headers is None: return None if version == \"HTTP/1.1\" and \"host\" not in headers: raise HttpError(400, \"HTTP/1.1 requests must have a Host header\") if \"transfer-encoding\" in headers: raise HttpError(501, \"Transfer-Encoding is not supported\") length = 0 if \"content-length\" in headers: raw = headers[\"content-length\"] if not (raw.isascii() and raw.isdigit()): raise HttpError(400, \"invalid Content-Length\") if len(raw) &gt; 10 or int(raw) &gt; MAX_BODY: raise HttpError(413) length = int(raw) expect = headers.get(\"expect\") if expect is not None and expect.lower() != \"100-continue\": raise HttpError(417) body = b\"\" if length: if expect: # the client is waiting for permission before it sends the body writer.write(b\"HTTP/1.1 100 Continue\\r\\n\\r\\n\") await writer.drain() try: body = await reader.readexactly(length) except asyncio.IncompleteReadError: return None return Request(method, target, version, headers, body)There are a lot of small decisions in there, and most of them exist because ambiguity is where security bugs come from. Strict parsing. The request line must have exactly three parts separated by single spaces. Header names must be made of token characters, so Host : x (a space before the colon) and folded continuation lines are rejected with 400 Bad Request. Lenient parsers are how two servers end up disagreeing about where a request ends. Limits. A line over 8 KB gets 414 (request line) or 431 (header line), more than 100 headers gets 431, and a body over 1 MB gets 413. A server with no limits lets any client make it allocate unlimited memory. The Host header. HTTP/1.1 requires it, because one server and IP address can host many sites. A missing Host is a 400. Content-Length validation. It must be digits only. I check isascii() as well as isdigit(), because Python's \"²\".isdigit() is True and int(\"²\") fails. I also check the length of the string before converting it, because Python 3.11 and later raises an error when you convert a very long digit string to an integer. Without that check, a header with 5,000 digits could crash the handler. Conflicting duplicate Content-Length headers are rejected too. Transfer-Encoding. This server does not support chunked request bodies, so any request that uses it gets 501 Not Implemented. Rejecting it outright also avoids a classic trap: if two programs in a chain disagree about whether Transfer-Encoding or Content-Length decides where a body ends, an attacker can hide a second request inside the first. That family of attacks is called request smuggling. Expect: 100-continue. Some clients, before sending a large body, send the headers and wait. The server can answer 100 Continue to say \"go ahead\" or reject the request right away. The code checks the size first, so an oversized upload is refused before a single body byte is sent. Line endings. The spec says lines end in \\r\\n, but it allows servers to accept a bare \\n. I accept both, which makes it easy to test by hand with tools that send only \\n. Step 2: Writing a response A response is a status, some headers, and a body. These small types and helpers build one: @dataclass class Response: status: int = 200 body: bytes = b\"\" headers: dict = field(default_factory=dict) def text_response(body, status=200, headers=None): return Response(status, body.encode(\"utf-8\"), {\"Content-Type\": \"text/plain; charset=utf-8\", **(headers or {})}) def html_response(body, status=200): return Response(status, body.encode(\"utf-8\"), {\"Content-Type\": \"text/html; charset=utf-8\"}) def json_response(data, status=200): body = (json.dumps(data, indent=2) + \"\\n\").encode(\"utf-8\") return Response(status, body, {\"Content-Type\": \"application/json\"}) def not_found(): return text_response(\"404 Not Found\\n\", 404)The part that turns a Response into bytes is serialize, along with the keep-alive decision: def wants_keep_alive(request): tokens = {t.strip() for t in request.header(\"connection\", \"\").lower().split(\",\")} if request.version == \"HTTP/1.1\": return \"close\" not in tokens # 1.1 connections stay open unless someone says otherwise return \"keep-alive\" in tokens # 1.0 connections close unless someone says otherwise def serialize(response, method, keep_alive): \"\"\"Turn a Response into the exact bytes that go on the wire.\"\"\" status = response.status reason = HTTPStatus(status).phrase bodyless = status &lt; 200 or status in (204, 304) headers = {\"Date\": formatdate(usegmt=True), \"Server\": SERVER_NAME} headers.update(response.headers) if not bodyless: headers.setdefault(\"Content-Length\", str(len(response.body))) headers[\"Connection\"] = \"keep-alive\" if keep_alive else \"close\" lines = [f\"HTTP/1.1 {status} {reason}\"] for name, value in headers.items(): if \"\\r\" in f\"{name}{value}\" or \"\\n\" in f\"{name}{value}\": raise ValueError(\"a header contains a line break\") # stops response splitting lines.append(f\"{name}: {value}\") head = (\"\\r\\n\".join(lines) + \"\\r\\n\\r\\n\").encode(\"latin-1\") body = b\"\" if bodyless or method == \"HEAD\" else response.body return head + bodyA few things here come straight from the HTTP rules: Content-Length is added automatically, so handlers cannot forget it. HEAD requests get the headers but no body. The Content-Length still describes the body a GET would have returned. That is the whole point of HEAD. 204 No Content, 304 Not Modified and 1xx responses never have a body. A server must not send Content-Length on a 204 or a 1xx, and a 304 carries no content either, so serialize leaves the header out for all three. Header values cannot contain a line break. If user input ever reaches a header, for example in a redirect, a \\r\\n in it would let an attacker add their own headers or even a fake second response. This is called response splitting. serialize raises an error instead, and the connection handler turns that into a 500. Keep-alive has different defaults per version. HTTP/1.1 keeps the connection open unless someone sends Connection: close. HTTP/1.0 closes unless the client sends Connection: keep-alive. Step 3: Routing A router maps a method and a path to a function. I wanted something like Flask's decorators in a few lines: class App: def __init__(self, root=None): self.root = os.path.realpath(root) if root else None self.routes = [] # (method, compiled pattern, handler) def route(self, method, pattern): \"\"\"Register a handler. A {name} in the pattern matches one path segment.\"\"\" parts = re.split(r\"\\{(\\w+)\\}\", pattern) regex = \"\".join( re.escape(part) if i % 2 == 0 else f\"(?P&lt;{part}&gt;[^/]+)\" for i, part in enumerate(parts) ) compiled = re.compile(regex) def register(handler): self.routes.append((method, compiled, handler)) return handler return register def dispatch(self, request): if request.method not in KNOWN_METHODS: return text_response(\"501 Not Implemented\\n\", 501) method = \"GET\" if request.method == \"HEAD\" else request.method # HEAD is GET without the body allowed = set() for route_method, regex, handler in self.routes: match = regex.fullmatch(request.path) if match is None: continue if route_method == method: return handler(request, **match.groupdict()) allowed.add(route_method) if self.root and not allowed: if method == \"GET\": return serve_file(self.root, request) if find_in_root(self.root, request.path) is not None: allowed.add(\"GET\") if allowed: if \"GET\" in allowed: allowed.add(\"HEAD\") return text_response(\"405 Method Not Allowed\\n\", 405, {\"Allow\": \", \".join(sorted(allowed))}) return not_found()A {name} in a route pattern matches one path segment and is passed to the handler as an argument. The literal parts are escaped, so a . in a route means a dot and not \"any character\". There is a test for exactly that. dispatch also settles three status codes that people often mix up: Situation Response The path matches nothing 404 Not Found The path exists, but not for this method 405 Method Not Allowed, with an Allow header listing what works The method is not one we recognize at all 501 Not Implemented HEAD support comes for free: a HEAD request is routed as a GET, and serialize drops the body. Step 4: A few endpoints that teach These are the built-in routes: def build_app(root=None): app = App(root) @app.route(\"GET\", \"/\") def index(request): return html_response(INDEX_HTML) @app.route(\"GET\", \"/hello\") def hello(request): name = request.query.get(\"name\", [\"world\"])[0] return text_response(f\"Hello, {name}!\\n\") @app.route(\"GET\", \"/time\") def current_time(request): return json_response({\"utc\": time.strftime(\"%Y-%m-%dT%H:%M:%SZ\", time.gmtime())}) @app.route(\"GET\", \"/headers\") def show_headers(request): return json_response(request.headers) @app.route(\"POST\", \"/echo\") def echo(request): content_type = request.header(\"content-type\", \"\").split(\";\")[0].strip().lower() if content_type == \"application/json\": try: data = json.loads(request.body) except ValueError: return json_response({\"error\": \"the body is not valid JSON\"}, 400) elif content_type == \"application/x-www-form-urlencoded\": data = parse_qs(request.body.decode(\"utf-8\", \"replace\")) else: data = request.body.decode(\"utf-8\", \"replace\") return json_response({\"content_type\": content_type or None, \"length\": len(request.body), \"data\": data}) @app.route(\"GET\", \"/old\") def old(request): return Response(301, b\"\", {\"Location\": \"/hello\"}) @app.route(\"GET\", \"/status/{code}\") def status(request, code): # A demo only: some statuses (401, 416 and others) normally need extra headers. phrase = None if code.isascii() and code.isdigit() and len(code) == 3 and 200 &lt;= int(code) &lt;= 599: try: phrase = HTTPStatus(int(code)).phrase except ValueError: pass if phrase is None: return text_response(\"Ask for a known status code from 200 to 599\\n\", 400) number = int(code) headers = {\"Location\": \"/hello\"} if number in (301, 302, 303, 307, 308) else {} body = \"\" if number in (204, 205, 304) else f\"{number} {phrase}\\n\" return text_response(body, number, headers) return app(The HTML for the home page is a short string called INDEX_HTML in the full file.) /headers is my favorite. It echoes back every header your client sent, so you can see what curl sends compared with a browser. /status/{code} returns whatever status code you ask for, which is handy for poking at how clients react to a 418 or a 503. Step 5: Serving files safely Serving files is where most people's first web server gets hacked. If the URL is /../../etc/passwd and you naively join it onto your web root, you hand out files that were never meant to be public. def guess_type(path): content_type = mimetypes.guess_type(path)[0] or \"application/octet-stream\" if content_type.startswith(\"text/\") or content_type == \"application/json\": content_type += \"; charset=utf-8\" return content_type def etag_matches(header, etag): if not header: return False candidates = [c.strip().removeprefix(\"W/\") for c in header.split(\",\")] return \"*\" in candidates or etag in candidates def find_in_root(root, url_path): \"\"\"Map a URL path to a file or directory inside root, or None. This is the check that stops /../../etc/passwd. We resolve the final path first (following .. and symlinks), then confirm it is still inside root. \"\"\" if \"\\0\" in url_path: return None candidate = os.path.realpath(os.path.join(root, url_path.lstrip(\"/\"))) if candidate != root and not candidate.startswith(root + os.sep): return None return candidate if os.path.exists(candidate) else None def serve_file(root, request): path = find_in_root(root, request.path) if path is not None and os.path.isdir(path): if not request.path.endswith(\"/\"): # Relative links inside a directory page only work with a trailing slash. return Response(301, b\"\", {\"Location\": \"/\" + quote(request.path.strip(\"/\")) + \"/\"}) path = find_in_root(root, request.path + \"index.html\") elif request.path.endswith(\"/\"): path = None # \"/file.txt/\" is not a thing if path is None or not os.path.isfile(path): return not_found() stat = os.stat(path) etag = f'\"{stat.st_mtime_ns:x}-{stat.st_size:x}\"' headers = {\"ETag\": etag, \"Cache-Control\": \"no-cache\"} if etag_matches(request.header(\"if-none-match\"), etag): return Response(304, b\"\", headers) # the browser's copy is still good with open(path, \"rb\") as f: body = f.read() headers[\"Content-Type\"] = guess_type(path) return Response(200, body, headers)find_in_root is the security check. The order matters: it first resolves the final path with os.path.realpath, which collapses every .. and follows symlinks, and only then checks that the result is still inside the web root. Checking the string before resolving it is the classic mistake. There is a subtler bug waiting too. If the root is /srv/public, then the check path.startswith(\"/srv/public\") would also allow /srv/public-evil/secret.txt. Comparing against the root plus a path separator fixes that, and there is a test for it. serve_file also shows three more pieces of HTTP: ETag and 304. Each file gets an ETag built from its modification time and size. A client that already has the file sends it back in If-None-Match, and if it still matches the server answers 304 Not Modified with no body. Here is the first response: HTTP/1.1 200 OK Date: Tue, 06 Oct 2026 04:58:04 GMT Server: tinyhttp/1.0 ETag: \"18dbd7d1f3e9b8be-12\" Cache-Control: no-cache Content-Type: text/plain; charset=utf-8 Content-Length: 18 Connection: close hello from a fileAnd the second request, which includes the ETag it just received: GET /hello.txt HTTP/1.1 Host: localhost If-None-Match: \"18dbd7d1f3e9b8be-12\" Connection: closeHTTP/1.1 304 Not Modified Date: Tue, 06 Oct 2026 04:58:04 GMT Server: tinyhttp/1.0 ETag: \"18dbd7d1f3e9b8be-12\" Cache-Control: no-cache Connection: closeCache-Control: no-cache does not mean \"never store this\". It means \"you may store it, but check with me before using it\". That is exactly what the 304 flow does. Directory redirects. A request for /docs redirects to /docs/ before serving docs/index.html. Relative links inside a page resolve against the URL's directory, so without the trailing slash they would point to the wrong place. The redirect target is rebuilt from the cleaned path, so a request for //docs cannot turn into Location: //docs/, which browsers read as a link to a different website. Content types. The Content-Type header comes from the file extension. Text types are labeled UTF-8, and unknown extensions fall back to application/octet-stream. A traversal attempt gets nothing: GET /../secret.txt HTTP/1.1 Host: localhost Connection: closeHTTP/1.1 404 Not Found Date: Tue, 06 Oct 2026 04:58:04 GMT Server: tinyhttp/1.0 Content-Type: text/plain; charset=utf-8 Content-Length: 14 Connection: close 404 Not FoundStep 6: The connection loop Everything so far handles one request. This last part handles connections, and it is where asyncio earns its place: def render(app, request): \"\"\"Run the handler and turn its Response into bytes. Any bug becomes a 500.\"\"\" keep_alive = wants_keep_alive(request) try: response = app.dispatch(request) data = serialize(response, request.method, keep_alive) except Exception: traceback.print_exc() response = text_response(\"500 Internal Server Error\\n\", 500) data = serialize(response, request.method, keep_alive) return response, data, keep_alive def log_access(client, request_line, status, size): # json.dumps escapes control characters so a client cannot forge log lines. print(f\"{client} {json.dumps(request_line)} {status} {size}\", flush=True) async def handle_connection(reader, writer, app, timeout=10, quiet=False): client = (writer.get_extra_info(\"peername\") or (\"-\",))[0] try: while True: # one connection can carry many requests try: request = await asyncio.wait_for(read_request(reader, writer), timeout) except HttpError as err: response = text_response(f\"{err.status} {err}\\n\", err.status) writer.write(serialize(response, \"GET\", keep_alive=False)) await writer.drain() if not quiet: log_access(client, \"-\", err.status, len(response.body)) break # after a bad request we cannot trust where the next one starts except asyncio.TimeoutError: break if request is None: break response, data, keep_alive = render(app, request) writer.write(data) await writer.drain() if not quiet: line = f\"{request.method} {request.target} {request.version}\" log_access(client, line, response.status, len(response.body)) if not keep_alive: break except ConnectionError: pass except Exception: traceback.print_exc() finally: writer.close() async def serve(app, host=\"127.0.0.1\", port=8000, timeout=10, quiet=False): server = await asyncio.start_server( lambda reader, writer: handle_connection(reader, writer, app, timeout, quiet), host, port, ) host, port = server.sockets[0].getsockname()[:2] print(f\"Listening on http://{host}:{port}\", flush=True) async with server: await server.serve_forever()if __name__ == \"__main__\": parser = argparse.ArgumentParser(description=\"A tiny HTTP server\") parser.add_argument(\"--host\", default=\"127.0.0.1\") parser.add_argument(\"--port\", type=int, default=8000) parser.add_argument(\"--root\", default=\"public\", help=\"folder of static files to serve\") parser.add_argument(\"--timeout\", type=float, default=10, help=\"seconds to wait for a request\") parser.add_argument(\"--quiet\", action=\"store_true\", help=\"do not print an access log\") options = parser.parse_args() app = build_app(options.root if os.path.isdir(options.root) else None) try: asyncio.run(serve(app, options.host, options.port, options.timeout, options.quiet)) except KeyboardInterrupt: passA few points on how it works: One connection, many requests. The while True loop is HTTP keep-alive. The connection stays open and the server reads the next request from the same stream, which saves a TCP handshake for every request. Clients can also send several requests without waiting for answers (pipelining), and the loop answers them in order. Timeouts. Every read is wrapped in asyncio.wait_for. A client that connects and says nothing, or sends half a request and stalls, is dropped after the timeout. Without it, a handful of idle sockets could hold the server's resources forever. After a bad request, hang up. Once a request is malformed, we cannot trust where the next one starts, so the connection is closed after the error response. Handler bugs become 500s. render catches any exception from a handler, logs the traceback on the server, and sends a generic 500 to the client. The client never sees internal details. Slow clients do not block others. Each connection is its own coroutine, so one stalled client does not hold up anyone else. One caveat: handlers are plain functions, so a handler that does slow blocking work will stall every connection. The access log escapes control characters. json.dumps quotes the request line, so a client cannot forge fake log lines or smuggle terminal escape codes into your logs. The access log looks like this (client, request line, status, body size): 127.0.0.1 \"GET /hello?name=ada HTTP/1.1\" 200 12 127.0.0.1 \"POST /echo HTTP/1.1\" 200 92 127.0.0.1 \"GET /hello.txt HTTP/1.1\" 200 18 127.0.0.1 \"GET /hello.txt HTTP/1.1\" 304 0 127.0.0.1 \"GET /../secret.txt HTTP/1.1\" 404 14 127.0.0.1 \"GET /old HTTP/1.1\" 301 0 127.0.0.1 \"HEAD /hello HTTP/1.1\" 200 14 127.0.0.1 \"-\" 400 46 127.0.0.1 \"POST /hello HTTP/1.1\" 405 23Try it Create a folder with a file to serve, and start the server: mkdir public echo \"hello from a file\" &gt; public/hello.txt python tinyhttp.pyIt listens on port 8000 and serves files from public. Use --port, --root, --timeout and --quiet to change the defaults. Then try these from another terminal: curl -i \"http://127.0.0.1:8000/hello?name=ada\" curl -i http://127.0.0.1:8000/hello.txt curl -I http://127.0.0.1:8000/hello curl -i -X POST -H \"Content-Type: application/json\" -d '{\"a\": 1}' http://127.0.0.1:8000/echo curl -iL http://127.0.0.1:8000/old curl -i http://127.0.0.1:8000/status/418 curl -i --path-as-is http://127.0.0.1:8000/../secret.txtAdd -v to any of them to see the exact request that curl sends. You can also type a request by hand, which is the best way to feel how simple the protocol is: printf 'GET /hello HTTP/1.1\\r\\nHost: x\\r\\nConnection: close\\r\\n\\r\\n' | nc 127.0.0.1 8000To see a rejection, send a request with no Host header: GET /hello HTTP/1.1HTTP/1.1 400 Bad Request Date: Tue, 06 Oct 2026 04:58:04 GMT Server: tinyhttp/1.0 Content-Type: text/plain; charset=utf-8 Content-Length: 46 Connection: close 400 HTTP/1.1 requests must have a Host headerAnd here is what a method that exists, but not on that path, looks like: HTTP/1.1 405 Method Not Allowed Date: Tue, 06 Oct 2026 04:58:04 GMT Server: tinyhttp/1.0 Content-Type: text/plain; charset=utf-8 Allow: GET, HEAD Content-Length: 23 Connection: close 405 Method Not AllowedTesting it I did not want to claim this works based on a few manual checks. The test suite has 99 tests. Most of them start the real server as a subprocess and send it raw bytes over TCP, using a small client I wrote separately from the server code. 12 of them use curl instead, to check the server against an implementation I did not write. A few more run the server inside the test process, so I can register handlers that crash on purpose. Here is what they cover: Routes and headers: query decoding, case-insensitive header names, repeated headers, trimmed values, standard response headers, and a Date that is actually current Methods and status codes: HEAD, 405 with Allow, 501, JSON, form and plain-text bodies, redirects, and the statuses that must not have a body Connections: keep-alive, Connection: close, HTTP/1.0 behavior, pipelining, requests split into tiny packets, bare \\n line endings, and idle and half-sent requests timing out Concurrency: a stalled client not blocking others, and 40 clients making 5 requests each at the same time Bad input: about 20 malformed requests, over-long lines, too many headers, bad and absurd Content-Length values, chunked bodies, Expect: 100-continue, and abrupt disconnects, including one that resets the connection while a 3 MB file is being sent Files: content types, empty and large files, percent-encoded and Unicode names, directory redirects, ETag and 304 handling, and a file that changes between requests Security: 17 path traversal attempts (encoded dots, double encoding, symlinks that point outside the root), plus null bytes, header injection, an open-redirect attempt, and log line forging Failures: handlers that raise, return nothing, return a bad status, or put a line break in a header all produce a 500 and leave the connection usable Run everything with python test_tinyhttp.py. The tail of the output looks like this: ---------------------------------------------------------------------- Ran 99 tests in 12.297s OKIf curl is not installed, the 12 curl tests are skipped and the rest still run. I ran the suite several times in a row while building this to make sure the timing-based tests were stable. What I learned HTTP is text, lines, and a length. There is a request line or status line, headers, a blank line, and a body whose size is stated up front. Once that clicks, a large part of web development stops feeling like magic. Content-Length is the backbone. Keep-alive, pipelining, HEAD, 304, and half the security problems all come back to the question \"where does this message end?\" Most of the work is at the edges. The happy path was a few dozen lines. The rest is limits, strict parsing, timeouts, and deciding what to do with input that is technically parseable but suspicious. Resolve first, then check. Both the path traversal check and the header injection check work because they look at the final value instead of trying to filter the input. What is missing This is a teaching server, not a production one. It has no HTTPS (TLS), no chunked request bodies, no compression, no range requests for resuming downloads, no cookies or sessions, no multipart form uploads, and no HTTP/2. Files are read into memory in one go and reading them blocks the event loop. There is no authentication, and no cap on how many connections one client can open. I only ran it on Linux with Python 3.12. For real work, put a mature server such as nginx or a framework's production server in front of your application. If you want to extend it, good next steps are chunked request bodies, Range support, gzip compression, and a Last-Modified header with If-Modified-Since.","contentHash":"sha256:8d8fda65c3a3e4b7710d01f0776eb12333af4162e2347d890ce42581e817cd1e","authorName":"Arpan Singh","authorUrl":"https://zyvop.com/author/arpan","authorSameAs":[],"category":null,"tags":["backend","Web Development","Python","Networking","http"],"audience":"Software engineers and developers building applications with backend","tone":"Instructional, practical, code-first","readingTimeMinutes":22,"wordCount":4890,"faqs":null,"primaryTopic":"backend","publishedAt":"2026-10-06T14:47:18.560Z","updatedAt":"2026-10-06T14:47:18.560Z","canonicalUrl":"https://zyvop.com/i-built-an-http-server-from-scratch-in-python-to-learn-how-http-really-works-jkzah"}