Skip to content

A Tiny HTTP Server with Just the Python Standard Library

2026-05-28

Sometimes you don't need Flask, FastAPI, or Starlette. You need a 50-line script that fakes an upstream API while you build the client, or stands in for a webhook during a demo, or serves a couple of JSON blobs from a colleague's laptop. Adding and documenting a framework dependency for that can be unnecessary overhead.

Python's http.server module already ships with everything you need. The default BaseHTTPRequestHandler API is just clunky enough that people reach for Flask instead. With about thirty lines of glue you can turn it into something that looks like a real micro-framework: route decorators, path parameters, JSON in and out.

This article walks through that glue. The complete library lives in tinyserver.py next to this article, and app.py shows how to use it.

The Bare Minimum

Here's what http.server gives you out of the box:

from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"hello\n")

HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

It works, but every endpoint becomes an if self.path == ... ladder inside do_GET. Adding a second method means another do_POST with its own ladder. We can do better without giving up the zero-dependency promise.

Goal: Route Decorators

What we want to write:

@route("GET", "/hello")
def hello(req: Request) -> dict:
    return {"message": "hello world"}

@route("GET", "/users/{id}")
def get_user(req: Request, id: str) -> dict:
    return {"id": id, "name": f"user-{id}"}

@route("POST", "/echo")
def echo(req: Request) -> Any:
    return req.json

Note that path parameters always arrive as strings — the regex captures text, so if you want an int you cast it inside the handler.

That's the target. Now let's build it.

The Router

A route is a (method, pattern, function) tuple. Patterns like /users/{id} need to be compiled into something we can match a real path against. A regex is fine:

import re
from typing import Any, Callable

Handler = Callable[..., Any]
ROUTES: list[tuple[str, re.Pattern[str], Handler]] = []

def route(method: str, pattern: str) -> Callable[[Handler], Handler]:
    parts = re.split(r"(\{\w+\})", pattern)
    regex_text = "".join(
        rf"(?P<{part[1:-1]}>[^/]+)" if part.startswith("{") else re.escape(part)
        for part in parts
    )
    regex = re.compile("^" + regex_text + "$")
    def decorator(func: Handler) -> Handler:
        ROUTES.append((method, regex, func))
        return func
    return decorator

{id} becomes a named capture group (?P<id>[^/]+). Everything outside the placeholders is escaped so a route containing a dot or another regex character still matches literally. When the path matches, match.groupdict() gives us a dict we can splat into the handler as keyword arguments.

The Request Object

The handler functions receive a req object. We don't need much — the method, the path, parsed query parameters, headers, and (for POSTs) a parsed JSON body:

import json
from http.server import BaseHTTPRequestHandler
from typing import Any
from urllib.parse import urlparse, parse_qs

class Request:
    def __init__(self, handler: BaseHTTPRequestHandler) -> None:
        parsed = urlparse(handler.path)
        self.method: str = handler.command
        self.path: str = parsed.path
        self.query: dict[str, str] = {k: v[0] for k, v in parse_qs(parsed.query).items()}
        self.headers = handler.headers
        length = int(handler.headers.get("Content-Length", 0))
        self.body: bytes = handler.rfile.read(length) if length else b""

    @property
    def json(self) -> Any:
        return json.loads(self.body) if self.body else None

@property keeps it lazy — if a handler doesn't touch req.json, we never parse it.

The Handler

Now wire it all together. A single BaseHTTPRequestHandler subclass dispatches every method through the route table:

from http.server import BaseHTTPRequestHandler, HTTPServer

class App(BaseHTTPRequestHandler):
    def _dispatch(self) -> None:
        req = Request(self)
        for method, regex, func in ROUTES:
            if method != req.method:
                continue
            m = regex.match(req.path)
            if not m:
                continue
            try:
                result = func(req, **m.groupdict())
            except Exception as e:
                return self._send(500, {"error": str(e)})
            return self._send(200, result)
        self._send(404, {"error": "not found"})

    def _send(self, status: int, body: Any) -> None:
        payload = json.dumps(body).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = _dispatch

    def log_message(self, fmt: str, *args: Any) -> None:
        print(f"{self.command} {self.path} -> {args[1]}")

The do_GET = do_POST = ... line is the trick that keeps this short. BaseHTTPRequestHandler dispatches on method by name, so we alias all of them to the same dispatcher. The override on log_message is optional but cleans up the noisy default access log.

Putting It Together

Wrap the server loop in a serve() helper so callers don't have to import ThreadingHTTPServer themselves:

def serve(host: str = "127.0.0.1", port: int = 8000) -> None:
    print(f"listening on http://{host}:{port}")
    ThreadingHTTPServer((host, port), App).serve_forever()

That's the whole library — about 60 lines including blanks. Save it as tinyserver.py and your own app file just needs to import from it:

from tinyserver import Request, route, serve

@route("GET", "/ping")
def ping(req: Request) -> dict:
    return {"ok": True}

if __name__ == "__main__":
    serve()

Routes register themselves via the @route decorator into the shared ROUTES list at import time, so the app file never touches it directly.

Returning Non-JSON

The _send helper assumes everything is JSON. For the simulator use case that's usually right, but sometimes you want to return a status code, or HTML, or an empty body. A common pattern is to let the handler return a tuple or a sentinel:

def _send_result(self, result: Any) -> None:
    if isinstance(result, tuple):
        status, body = result
    else:
        status, body = 200, result
    self._send(status, body)

Now handlers can write return 404, {"error": "no such user"} when they want a specific status.

Threading

The serve() helper above uses ThreadingHTTPServer so parallel client requests don't block each other — one thread per request. If you'd rather have strict single-threaded behaviour (easier debugging, no concurrency surprises), swap it for HTTPServer. Either way, don't share mutable state across handlers without a lock.

For the case where you just need to answer a few HTTP requests with canned data, Python has long shipped the necessary server pieces. (http.server is the Python 3 module; Python 2 split them across modules such as BaseHTTPServer.) They just needed a tiny bit of sugar on top.