KAHIBARO
Discord Login Register

6.1 Building a Simple Web Server

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:

  1. Listens on a network port (often port 8000 or 8080 during development).
  2. Waits for HTTP requests from clients, such as browsers or tools like curl.
  3. Reads the request, then decides how to respond.
  4. 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:

bash
python -m http.server 8000

This does the following:

Now open a browser and visit:

http://localhost:8000

You will see:

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:

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:

python
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:

bash
python basic_server.py

Visit:

http://localhost:8000

You should see the plain text response.

Breaking Down the Code

Inside do_GET:

  1. self.send_response(200)
    Sets the HTTP status code to 200 OK.
  2. self.send_header("Content-Type", "text/plain; charset=utf-8")
    Adds a header. You must call self.end_headers() after sending all headers.
  3. 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`

python
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:

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:

Each one is called when a request with the corresponding HTTP method arrives.

Example: Support GET and POST

Create method_server.py:

python
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:

bash
python method_server.py

In another terminal, send a GET request:

bash
curl http://localhost:8002/items

You should get a JSON list.

Now send a POST with JSON:

bash
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-Length header.
  • 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:

CodeMeaningTypical Use
200OKSuccessful GET, PUT, PATCH, DELETE
201CreatedNew resource created, often for POST
400Bad RequestInvalid input, malformed JSON
404Not FoundPath or resource does not exist
405Method Not AllowedHTTP method not supported for this endpoint
500Internal Server ErrorUnhandled exception on the server

Returning 404 for Unknown Paths

You already saw an example:

python
self._send_text("Not found", status=404)

Returning 400 for Bad Data

In the POST example:

python
if data is None:
    self._send_json({"error": "Invalid JSON"}, status=400)
    return

Use 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.

python
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.

python
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:

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:

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

Comments

Please login to add a comment.

Don't have an account? Register now!