ZetCode

Python http.server

last modified October 29, 2026 Python 3.15

In this article we show how to use the http.server module. The module serves files from the command line and provides classes for writing simple HTTP servers.

The module contains server classes and request handler classes. The HTTPServer and ThreadingHTTPServer classes listen for connections; their HTTPS counterparts HTTPSServer and ThreadingHTTPSServer were added in Python 3.14. A request handler class processes each request. The BaseHTTPRequestHandler class parses requests and dispatches them to do_GET, do_POST, and similar methods, which we write ourselves. The SimpleHTTPRequestHandler class serves files from a directory.

The http.server module is meant for development, testing, and local file sharing. It is not recommended for production, because it implements only basic security checks. All the examples were run with Python 3.15; the -H option, the extra_response_headers parameter, and the default_content_type attribute are new in that version.

Serving files from the command line

The quickest way to use the module is to run it with the -m option. It serves the current directory, or the directory given with -d, on port 8000.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Home page</title>
</head>
<body>
<p>Served by Python http.server</p>
</body>
</html>

We place this index.html file into a public directory. When a directory contains an index.html or index.htm file, the server returns it instead of a directory listing.

The server prints a line for each request. Here, we requested the home page and a favicon.ico file, as a browser does; the icon does not exist, so the server logs a 404 error. The server runs until we press Ctrl+C.

$ python -m http.server -d public
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
127.0.0.1 - - [29/Oct/2026 22:22:26] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [29/Oct/2026 22:22:26] code 404, message File not found
127.0.0.1 - - [29/Oct/2026 22:22:26] "GET /favicon.ico HTTP/1.1" 404 -

Inspecting the response

In another terminal, we can request the page with curl. The -i option includes the response headers in the output.

The Content-type header is guessed from the file extension. The Last-Modified header contains the modification time of the file, and browsers use it for caching. By default, the server speaks HTTP/1.0 and closes the connection after each response; the -p HTTP/1.1 option enables persistent connections.

$ curl -i localhost:8000/
HTTP/1.0 200 OK
Server: SimpleHTTP Python/3.15.2
Date: Thu, 29 Oct 2026 21:22:28 GMT
Content-type: text/html
Content-Length: 155
Last-Modified: Thu, 29 Oct 2026 21:22:27 GMT

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Home page</title>
</head>
<body>
<p>Served by Python http.server</p>
</body>
</html>

Command line options

The command accepts several options. Here we bind the server to the loopback interface, choose port 9000, and use two options added in Python 3.15.

By default, the server listens on all network interfaces, so other computers on the network can reach it. The -b 127.0.0.1 option makes it accessible only from the local machine. The -H option adds a response header and can be repeated. The --content-type option sets the content type for files with an unknown extension, such as README; previously, such files were always sent as application/octet-stream. The -I option of curl sends a HEAD request, which returns only the headers.

$ python -m http.server -b 127.0.0.1 -d public -H Cache-Control no-store --content-type 'text/plain; charset=utf-8' 9000 &
$ curl -I 127.0.0.1:9000/README
HTTP/1.0 200 OK
Server: SimpleHTTP Python/3.15.2
Date: Thu, 29 Oct 2026 21:22:29 GMT
Content-type: text/plain; charset=utf-8
Content-Length: 15
Last-Modified: Thu, 29 Oct 2026 21:22:28 GMT
Cache-Control: no-store

Directory listings

For a directory without an index page, the server generates a listing of its files.

The docs directory contains two files and an images subdirectory. Subdirectories end with a slash in the listing. Hidden files are listed as well, so we must be careful about which directory we share.

$ curl localhost:8000/
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
<style type="text/css">
:root {
color-scheme: light dark;
}
</style>
<title>Directory listing for /</title>
</head>
<body>
<h1>Directory listing for /</h1>
<hr>
<ul>
<li><a href="data.csv">data.csv</a></li>
<li><a href="images/">images/</a></li>
<li><a href="notes.txt">notes.txt</a></li>
</ul>
<hr>
</body>
</html>

A custom request handler

To generate responses in code, we subclass BaseHTTPRequestHandler and define a method for each HTTP method we support.

hello_server.py
from http.server import BaseHTTPRequestHandler, HTTPServer


class HelloHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        body = 'Hello there!\n'.encode('utf-8')

        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)


with HTTPServer(('localhost', 8000), HelloHandler) as server:
    print('Serving on http://localhost:8000')
    server.serve_forever()

The server answers every GET request with a short text.

self.send_response(200)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)

A response is written in a fixed order: the status line, the headers, an empty line, and the body. The send_response method also adds the Server and Date headers. The body is written to the wfile stream as bytes.

with HTTPServer(('localhost', 8000), HelloHandler) as server:
    server.serve_forever()

The server is created with an address and the handler class. A new handler instance is created for every request. The serve_forever method handles requests until the program is stopped. Requests for methods without a handler method, such as POST here, get a 501 error.

$ curl -i localhost:8000/
HTTP/1.0 200 OK
Server: BaseHTTP Python/3.15.2
Date: Thu, 29 Oct 2026 21:22:31 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 13

Hello there!

Query parameters

The path attribute of the handler contains the requested path including the query string. We parse it with the urllib.parse module.

query_server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlsplit


class GreetHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        url = urlsplit(self.path)

        if url.path != '/greet':
            self.send_error(404, 'Not Found')
            return

        query = parse_qs(url.query)
        name = query.get('name', ['guest'])[0]
        times = int(query.get('times', ['1'])[0])

        body = f'Hello {name}!\n'.encode('utf-8') * times

        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)


with HTTPServer(('localhost', 8000), GreetHandler) as server:
    server.serve_forever()

The server greets the user given in the name parameter.

url = urlsplit(self.path)

if url.path != '/greet':
    self.send_error(404, 'Not Found')
    return

The urlsplit function separates the path from the query. For any other path, send_error sends a complete error response with an HTML page.

query = parse_qs(url.query)
name = query.get('name', ['guest'])[0]

The parse_qs function decodes the query string into a dictionary of lists, because a parameter may occur several times. Percent-encoded characters are decoded, so J%C3%A1n becomes Ján.

$ curl 'localhost:8000/greet?name=Lucy'
Hello Lucy!
$ curl 'localhost:8000/greet?name=J%C3%A1n&times=2'
Hello Ján!
Hello Ján!
$ curl localhost:8000/greet
Hello guest!
$ curl -i localhost:8000/hello
HTTP/1.0 404 Not Found
Server: BaseHTTP Python/3.15.2
Date: Thu, 29 Oct 2026 21:22:32 GMT
Connection: close
Content-Type: text/html;charset=utf-8
Content-Length: 455

<!DOCTYPE HTML>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <style type="text/css">
            :root {
                color-scheme: light dark;
            }
        </style>
        <title>Error response</title>
    </head>
    <body>
        <h1>Error response</h1>
        <p>Error code: 404</p>
        <p>Message: Not Found.</p>
        <p>Error code explanation: 404 - Nothing matches the given URI.</p>
    </body>
</html>

A JSON API

The next server provides a small REST API over an in-memory dictionary of users.

json_server.py
import json
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

users = {
    1: {'id': 1, 'name': 'John Doe', 'occupation': 'gardener'},
    2: {'id': 2, 'name': 'Roger Roe', 'occupation': 'driver'},
}


class UserHandler(BaseHTTPRequestHandler):

    def send_json(self, status, data, headers=()):
        body = json.dumps(data).encode('utf-8')
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        for name, value in headers:
            self.send_header(name, value)
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        parts = self.path.strip('/').split('/')

        if parts == ['users']:
            self.send_json(HTTPStatus.OK, list(users.values()))
        elif len(parts) == 2 and parts[0] == 'users' and parts[1].isdigit():
            user = users.get(int(parts[1]))
            if user is None:
                self.send_json(HTTPStatus.NOT_FOUND, {'error': 'user not found'})
            else:
                self.send_json(HTTPStatus.OK, user)
        else:
            self.send_json(HTTPStatus.NOT_FOUND, {'error': 'unknown path'})

    def do_POST(self):
        if self.path != '/users':
            self.send_json(HTTPStatus.NOT_FOUND, {'error': 'unknown path'})
            return

        length = int(self.headers.get('Content-Length', 0))

        try:
            data = json.loads(self.rfile.read(length))
            user = {'id': max(users) + 1, 'name': data['name'],
                    'occupation': data['occupation']}
        except (json.JSONDecodeError, KeyError, TypeError):
            self.send_json(HTTPStatus.BAD_REQUEST, {'error': 'invalid user data'})
            return

        users[user['id']] = user
        self.send_json(HTTPStatus.CREATED, user,
                       [('Location', f'/users/{user["id"]}')])


with ThreadingHTTPServer(('localhost', 8000), UserHandler) as server:
    server.serve_forever()

The send_json helper serializes the data and sends it with the application/json content type.

parts = self.path.strip('/').split('/')

The do_GET method splits the path into segments. /users returns all users and /users/2 one user; a missing user produces a 404 response with a JSON error message.

self.send_json(HTTPStatus.OK, list(users.values()))

The HTTPStatus enumeration from the http module gives the status codes readable names.

$ curl localhost:8000/users
[{"id": 1, "name": "John Doe", "occupation": "gardener"}, {"id": 2, "name": "Roger Roe", "occupation": "driver"}]
$ curl localhost:8000/users/2
{"id": 2, "name": "Roger Roe", "occupation": "driver"}
$ curl -i localhost:8000/users/7
HTTP/1.0 404 Not Found
Server: BaseHTTP Python/3.15.2
Date: Thu, 29 Oct 2026 21:22:33 GMT
Content-Type: application/json
Content-Length: 27

{"error": "user not found"}

Handling POST requests

The do_POST method of the same server creates new users. The request body must be read from the rfile stream.

length = int(self.headers.get('Content-Length', 0))

try:
    data = json.loads(self.rfile.read(length))

The headers attribute holds the request headers. We must read exactly Content-Length bytes; reading more would block, because the client keeps the connection open while it waits for the response.

self.send_json(HTTPStatus.CREATED, user,
               [('Location', f'/users/{user["id"]}')])

A created resource is answered with the 201 status and a Location header pointing to the new user. Invalid JSON or missing fields produce a 400 response. The -d option makes curl send a POST request with the given body.

$ curl -i -d '{"name": "Lucy Smith", "occupation": "teacher"}' localhost:8000/users
HTTP/1.0 201 Created
Server: BaseHTTP Python/3.15.2
Date: Thu, 29 Oct 2026 21:22:34 GMT
Content-Type: application/json
Content-Length: 56
Location: /users/3

{"id": 3, "name": "Lucy Smith", "occupation": "teacher"}
$ curl -d '{"name": "Tom"}' localhost:8000/users
{"error": "invalid user data"}
$ curl localhost:8000/users/3
{"id": 3, "name": "Lucy Smith", "occupation": "teacher"}

ThreadingHTTPServer

The HTTPServer class handles one request at a time. A slow request blocks all others. The ThreadingHTTPServer class handles each request in a new thread.

threading_demo.py
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
from urllib.request import urlopen


class SlowHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        time.sleep(0.5)
        body = b'done\n'
        self.send_response(200)
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format, *args):
        pass


def measure(server_class):
    with server_class(('localhost', 0), SlowHandler) as server:
        threading.Thread(target=server.serve_forever, daemon=True).start()
        url = f'http://localhost:{server.server_port}/'

        start = time.perf_counter()
        with ThreadPoolExecutor(4) as pool:
            replies = list(pool.map(lambda _: urlopen(url).read(), range(4)))
        elapsed = time.perf_counter() - start

        server.shutdown()

    print(f'{server_class.__name__:20} {len(replies)} requests in {elapsed:.1f} s')


measure(HTTPServer)
measure(ThreadingHTTPServer)

Each request takes half a second. We send four requests at once and measure how long they take with each server class.

with server_class(('localhost', 0), SlowHandler) as server:
    threading.Thread(target=server.serve_forever, daemon=True).start()

Port 0 lets the operating system choose a free port, which we read from server_port. The server runs in a background thread, so the same program can act as a client. This setup is also useful in tests. The shutdown method stops the serve_forever loop.

def log_message(self, format, *args):
    pass

Overriding log_message silences the request log. The single threaded server processes the requests one after another, while the threading server handles them in parallel.

$ python threading_demo.py
HTTPServer           4 requests in 2.0 s
ThreadingHTTPServer  4 requests in 0.5 s

Serving files with extra headers

The SimpleHTTPRequestHandler class can be used in our own programs and customized by subclassing.

static_server.py
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer


class StaticHandler(SimpleHTTPRequestHandler):

    default_content_type = 'text/plain; charset=utf-8'

    def log_request(self, code='-', size='-'):
        print(f'{self.command} {self.path} -> {code}', flush=True)

    def log_error(self, format, *args):
        pass


handler = partial(
    StaticHandler,
    directory='public',
    extra_response_headers=[
        ('Cache-Control', 'max-age=60'),
        ('X-Content-Type-Options', 'nosniff'),
    ],
)

with ThreadingHTTPServer(('localhost', 8000), handler) as server:
    print('Serving public/ on http://localhost:8000', flush=True)
    server.serve_forever()

The server serves the public directory and adds headers to every response.

handler = partial(
    StaticHandler,
    directory='public',
    extra_response_headers=[
        ('Cache-Control', 'max-age=60'),
        ('X-Content-Type-Options', 'nosniff'),
    ],
)

The server creates handler instances itself, so we use functools.partial to pass the constructor arguments. The directory parameter sets the served directory without changing the working directory of the program. The extra_response_headers parameter, new in Python 3.15, takes a sequence of name-value pairs.

default_content_type = 'text/plain; charset=utf-8'

The default_content_type class attribute is the programmatic equivalent of the --content-type option.

$ curl -I localhost:8000/README
HTTP/1.0 200 OK
Server: SimpleHTTP Python/3.15.2
Date: Thu, 29 Oct 2026 21:22:35 GMT
Content-type: text/plain; charset=utf-8
Content-Length: 15
Last-Modified: Thu, 29 Oct 2026 21:22:34 GMT
Cache-Control: max-age=60
X-Content-Type-Options: nosniff

Custom request logging

The handler of the previous example also replaces the default request log format. Its log_request method is called by send_response for every response.

def log_request(self, code='-', size='-'):
    print(f'{self.command} {self.path} -> {code}', flush=True)

def log_error(self, format, *args):
    pass

We print the method, the path, and the status code. The default log_error method logs an extra line for every error response; we turn it off, because the status code already shows the error. The flush=True argument makes the lines appear immediately even when the output is redirected to a file.

$ python static_server.py
Serving public/ on http://localhost:8000
GET / -> 200
GET /README -> 200
GET /missing.html -> 404

HTTPS

Since Python 3.14, the command line server supports HTTPS with the --tls-cert and --tls-key options. For local testing, we create a self-signed certificate with openssl.

The openssl req command creates a private key in key.pem and a certificate for localhost in cert.pem, valid for 30 days. Since no certificate authority has signed it, curl refuses the connection at first. The -k option disables the certificate check. In programs, the same server is available as the HTTPSServer class with the certfile and keyfile parameters.

$ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout key.pem -out cert.pem -days 30 -subj /CN=localhost
-----
$ python -m http.server -d public --tls-cert cert.pem --tls-key key.pem 8443 &
$ curl https://localhost:8443/
curl: (60) SSL certificate problem: self-signed certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.
$ curl -k https://localhost:8443/
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Home page</title>
</head>
<body>
<p>Served by Python http.server</p>
</body>
</html>

Source

http.server – HTTP servers

In this article we have worked with the http.server module. See also Python GET/POST request and Python Requests.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.