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.
<!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.
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.
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×=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.
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.
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.
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
In this article we have worked with the http.server module. See
also Python GET/POST request and
Python Requests.
Author
List all Python tutorials.