6.1 Building a Simple Web Server
Table of Contents
Understanding What You Are Building
Before you write any code, it helps to understand what a "simple web server" actually does.
At the most basic level, a web server:
- Listens on a network port (often port 8000 or 8080 during development).
- Waits for HTTP requests from clients, such as browsers or tools like
curl. - Reads the request, then decides how to respond.
- Sends back an HTTP response: a status line, headers, and an optional body (like HTML or JSON).
You already covered how HTTP and requests / responses work in earlier chapters, so here we will focus on building such a server, step by step, using Python.
Using Python’s Built-in HTTP Server
Python includes modules that let you create a very simple web server with almost no code. These are useful for quick tests and for understanding what a server does behind the scenes.
The Simplest Possible Server
If you only want to serve files from a folder over HTTP, Python gives you a one-liner.
From a terminal, run:
python -m http.server 8000This does the following:
- Starts a server on port
8000. - Uses the current directory as the web root.
- Serves files using the
GETmethod.
Now open a browser and visit:
http://localhost:8000You will see:
- A directory listing of your current folder, or
- If there is an
index.htmlfile, that file will be served.
You can stop the server with Ctrl + C in the terminal.
This built-in server is useful to quickly test HTML, CSS, or JavaScript, but it is very limited:
- You cannot easily change how requests are handled.
- You do not control the logic of responses.
To really understand web backends, you need to handle requests yourself.
Building a Minimal HTTP Server in Python
Python’s http.server module lets you write your own request handler. This gives you control over what gets returned.
A Basic “Hello, World” Web Server
Create a file called basic_server.py:
from http.server import HTTPServer, BaseHTTPRequestHandler
class SimpleHandler(BaseHTTPRequestHandler):
def do_GET(self):
# Set status code
self.send_response(200)
# Set headers
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
# Set response body
message = "Hello, world! This is my first web server."
self.wfile.write(message.encode("utf-8"))
def run_server():
host = "localhost"
port = 8000
server_address = (host, port)
httpd = HTTPServer(server_address, SimpleHandler)
print(f"Serving on http://{host}:{port}")
httpd.serve_forever()
if __name__ == "__main__":
run_server()Run it:
python basic_server.pyVisit:
http://localhost:8000You should see the plain text response.
Breaking Down the Code
HTTPServer:- Manages the socket.
- Listens for incoming connections.
- Passes each request to your handler.
BaseHTTPRequestHandler:- Represents a single HTTP request.
- Provides methods like
do_GET,do_POST, etc. - You override these to control behavior.
do_GET(self):- Called automatically for every incoming GET request.
Inside do_GET:
self.send_response(200)
Sets the HTTP status code to 200 OK.self.send_header("Content-Type", "text/plain; charset=utf-8")
Adds a header. You must callself.end_headers()after sending all headers.self.wfile.write(...)
Writes the body of the response as bytes.
Always send headers before writing the response body, and always write bytes, not strings.
If you forget self.end_headers(), most clients will not understand the response correctly.
Handling Different Paths (Routes)
Most real servers respond differently based on the URL path, for example /, /about, /api/users.
In a very simple server without a framework, you inspect self.path and branch manually.
Example: Multiple Paths in `do_GET`
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
class SimpleRouterHandler(BaseHTTPRequestHandler):
def _send_json(self, data, status=200):
response_bytes = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(response_bytes)))
self.end_headers()
self.wfile.write(response_bytes)
def _send_text(self, text, status=200):
response_bytes = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(response_bytes)))
self.end_headers()
self.wfile.write(response_bytes)
def do_GET(self):
if self.path == "/":
self._send_text("Welcome to the home page")
elif self.path == "/hello":
self._send_text("Hello from /hello")
elif self.path == "/api/info":
data = {"version": "1.0.0", "status": "ok"}
self._send_json(data)
else:
self._send_text("Not found", status=404)
def run_server():
server = HTTPServer(("localhost", 8001), SimpleRouterHandler)
print("Serving on http://localhost:8001")
server.serve_forever()
if __name__ == "__main__":
run_server()Try visiting:
http://localhost:8001/http://localhost:8001/hellohttp://localhost:8001/api/infohttp://localhost:8001/unknown
You will see different responses for each path.
Here we manually implemented a tiny routing system: a chain of if statements based on self.path.
In a real backend framework, you will not write these if statements yourself. Instead, the framework will give you decorators or functions to map URLs to functions. But this manual approach helps you see what is happening underneath.
Handling Different HTTP Methods
A web server does not only handle GET. You will often need POST for creating data, PUT or PATCH for updating, and DELETE for removing.
BaseHTTPRequestHandler lets you define methods such as:
do_GETdo_POSTdo_PUTdo_PATCHdo_DELETE
Each one is called when a request with the corresponding HTTP method arrives.
Example: Support GET and POST
Create method_server.py:
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
class MethodHandler(BaseHTTPRequestHandler):
def _parse_json_body(self):
# Read Content-Length header
content_length_header = self.headers.get("Content-Length")
if not content_length_header:
return None
try:
length = int(content_length_header)
except ValueError:
return None
# Read raw bytes
raw_body = self.rfile.read(length)
try:
return json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
return None
def _send_json(self, data, status=200):
response_bytes = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(response_bytes)))
self.end_headers()
self.wfile.write(response_bytes)
def do_GET(self):
if self.path == "/items":
items = [
{"id": 1, "name": "Apple"},
{"id": 2, "name": "Banana"},
]
self._send_json(items)
else:
self._send_json({"error": "Not found"}, status=404)
def do_POST(self):
if self.path == "/items":
data = self._parse_json_body()
if data is None:
self._send_json({"error": "Invalid JSON"}, status=400)
return
# In a real app you would save to a database.
# Here we just echo back what we received.
created_item = {
"id": 123,
"name": data.get("name"),
}
self._send_json(created_item, status=201)
else:
self._send_json({"error": "Not found"}, status=404)
def run_server():
server = HTTPServer(("localhost", 8002), MethodHandler)
print("Serving on http://localhost:8002")
server.serve_forever()
if __name__ == "__main__":
run_server()Run:
python method_server.pyIn another terminal, send a GET request:
curl http://localhost:8002/itemsYou should get a JSON list.
Now send a POST with JSON:
curl -X POST http://localhost:8002/items \
-H "Content-Type: application/json" \
-d '{"name": "Orange"}'You will get back a JSON object representing the created item.
When handling POST or PUT requests with a body:
- Always read exactly the number of bytes specified in the
Content-Lengthheader. - Always decode bytes using the correct charset, commonly UTF-8.
- Always validate or at least check the parsed data before using it.
Simple Error Handling and Status Codes
Even a basic web server should use meaningful HTTP status codes.
Common ones you will use here:
| Code | Meaning | Typical Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, DELETE |
| 201 | Created | New resource created, often for POST |
| 400 | Bad Request | Invalid input, malformed JSON |
| 404 | Not Found | Path or resource does not exist |
| 405 | Method Not Allowed | HTTP method not supported for this endpoint |
| 500 | Internal Server Error | Unhandled exception on the server |
Returning 404 for Unknown Paths
You already saw an example:
self._send_text("Not found", status=404)Returning 400 for Bad Data
In the POST example:
if data is None:
self._send_json({"error": "Invalid JSON"}, status=400)
returnUse status codes consistently. Do not return 200 OK for requests that clearly failed.
Frameworks will automate much of this for you, but understanding how to choose codes is important.
Logging Requests for Debugging
When building servers, it helps to see incoming requests.
BaseHTTPRequestHandler already logs some information, but you can customize it.
from http.server import HTTPServer, BaseHTTPRequestHandler
import time
class LoggingHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Override default logging format
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
message = "%s - %s" % (self.address_string(), format % args)
print(f"[{timestamp}] {message}")
def do_GET(self):
print(f"Handling GET {self.path}")
self.send_response(200)
self.end_headers()
self.wfile.write(b"Check the server logs for request info.")Now every request will appear with a custom log line in your terminal.
Logging will become a bigger topic later, but even in simple servers it is useful to know where to add print or logging calls to see what is happening.
Serving Simple HTML
So far we returned plain text or JSON. Backend servers often return HTML, especially in projects that use server-side templates.
You can send HTML the same way you send text. You only change the Content-Type.
from http.server import HTTPServer, BaseHTTPRequestHandler
class HTMLHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/":
html = """
<!DOCTYPE html>
<html>
<head>
<title>My Simple Server</title>
</head>
<body>
<h1>Hello from a simple HTML page</h1>
<p>This HTML comes from a Python server.</p>
</body>
</html>
"""
html_bytes = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(html_bytes)))
self.end_headers()
self.wfile.write(html_bytes)
else:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not found")
def run_server():
server = HTTPServer(("localhost", 8003), HTMLHandler)
print("Serving on http://localhost:8003")
server.serve_forever()
if __name__ == "__main__":
run_server()
Visit http://localhost:8003/ and you will see an HTML page rendered in the browser.
In later chapters you will learn about templates that let you keep HTML in separate files and insert dynamic content. For now this example shows the basic idea.
Why Frameworks Exist
The servers you are building here are extremely simple and lack many things you actually want:
- Easy URL routing without
if self.path == .... - Automatic parsing of query parameters and request bodies.
- Simple JSON handling.
- Built-in validation of input.
- Automatic 404, 405, and error responses.
- Middleware support (for logging, authentication, etc).
- Integration with templates, databases, and more.
It is possible to build all of this with the standard library, but it is a lot of code and easy to get wrong.
Frameworks like FastAPI, Flask, or Django sit on top of lower level servers and give you:
- A nicer API for defining routes.
- Helper functions for returning responses.
- Tools for common backend tasks.
This chapter focused on a "from scratch" approach so you know what is going on under the hood. In the rest of this section you will build on these ideas using actual web frameworks that remove much of the boilerplate and give you a more productive way to build real applications.
Views: 12
KAHIBARO