Advanced
Once your core application is complete, a plugin architecture can help you to extend the functionality very easily. With a plugin architecture, you can simply write the core application, and then extend the functionality in the future much more easily. Without a plugin architecture, it can be quite difficult to do this since you will be afraid that you will break the original functionality.
So why don’t do this all the time? Well it does take more planning effort in the beginning in order to reap the rewards in the future, and most of us (myself included) are often too impatient to do that. However, there are some methods that you can take in order to embed a plugin desirable to extend the functionality. Last time we looked at using importlib (see our previous article “A Plugin Architecture using importlib“), and this time we have an even simpler library called pyplugs.
When to use plugin architecture
So when should you use a plugin architecture? Here are several scenarios – they are all around separating the code from the core to the variations:
- Separate Functionality: When you can split the problem you’re trying to solve/application from core functionality (the main “engine”) to the variations: e.g. ranking cheapest flights where data is from different websites. The core application/engine is the ranking logic. The data extraction from different websites would each be a plugin – website 1 = plugin 1, website 2 = plugin2. When you want to add a new website, you just need to add a new plugin
- Distribute Development Effort: When you want to work in a team to easily separate the focus from core functionality to variations: e.g. suppose you have an application to do image recognition. Team 1 (e.g. data science team) can work on the core engine of doing the image recognition, while you can have Team 2-4 work on creating different plugins for different image formats (e.g. Team 2: read in JPG files, Team 3: read in PNG files, etc)
- Launch sooner and add functionality in future: When you want to launch an application as quickly as possible. e.g. Suppose you want to create an application to return the number of working days from different countries. To begin with, you can just start by launching this for United States and Australia. Then, you can add more countries in the future. Since you designed the plugin architecture from the start, it’ll be safer to add more countries.
There are many more, but the disadvantage is that you have to plan for it upfront. Invest now in a plugin architecture, and then reap the benefits in the future.
Invest now in a plugin architecture, and then reap the benefits in the future

Let’s explore this third example of a public holiday counter application and show how the pyplugs library can help.
Example Problem: Extracting Public Holidays
The application we’d like to create is a command line application that can be used to pass in a location (country and/or state), and then return the list of public holidays in 2020:
The pseudo-code will be as follows:
1. Get location
2. If data for location not available, then error
3. Get the list of all holidays from the location
4. Return the list of working days
As you probably guessed, it’s step 3 that can be converted into a plugin. However, let’s start without a plugin architecture and do this the normal way.
First let’s see where we can get the data from – for UK data you can get this from publicholidays.co.uk:

And then for Singapore data, you can get it from jalanow.com:

In both cases, the data is in a HTML Table view where the data is in a <td> tag. We will need to use regular expressions to extract the data.
Here’s the code for non-plugin approach:
#pubholiday.py
import argparse
import requests, re
G_COUNTRIES = ['UK', 'SG']
def get_working_days(args):
if args.countrycode =='UK':
r = requests.get( 'https://publicholidays.co.uk/2020-dates/')
m = re.findall('<tr class.+?><td>(.+?)<\/td>', r.text)
return list(set(m))
elif args.countrycode =='SG':
r = requests.get('https://www.jalanow.com/singapore-holidays-2021.htm')
m = re.findall('<td class\=\"crDate\">(.+?)<\/td>', r.text)
return list(set(m))
def setup_args():
parser = argparse.ArgumentParser(description='Get list of public holidays in a given year')
parser.add_argument('-c', '--countrycode', required=True, type=str, choices=G_COUNTRIES, help='Country code')
return parser
if __name__ == '__main__':
parser = setup_args()
args = parser.parse_args()
print( get_working_days(args) )
Running the above with no arguments gives the following – the argparse is a useful library to create arguments very easily – see our other article How to use argparse to manage arguments.

Now, when we run the application with either UK or SG, we get the following data:

The way the code works is all from the function get_working_days:
def get_working_days(args):
if args.countrycode =='UK':
r = requests.get( 'https://publicholidays.co.uk/2020-dates/')
m = re.findall('<tr class.+?><td>(.+?)<\/td>', r.text)
return list(set(m))
elif args.countrycode =='SG':
r = requests.get('https://www.jalanow.com/singapore-holidays-2021.htm')
m = re.findall('<td class\=\"crDate\">(.+?)<\/td>', r.text)
return list(set(m))
The code for UK, for examples works the following way:
1. Get the data using the requests to the website. All the data will be in a r.text
2. Next, run a regular expression to extract the date data from the <TD> tag
3. Finally, remove duplicates with the list(set(m)) code
The disadvantage with this code is that if we add more countries, the function get_working_days() will become longer and longer with complex IF statements. The other challenge is testing it, either manually or with pytest will become quite painful. We can always have it call a dynamic function, but then we end up having difficult to read code.
What we need is a dynamic way to call a function for each country so that it can be easily maintainable and extendible… this is where a plugin architecture will help.
Extracting Public Holidays with a plugin architecture using pyplugs
What we will do now is to separate the main core logic from the plugins. So the file structure will be as follows:
|--- pubholidays.py
|___ plugins\
|___________ __init__.py
|___________ reader_UK.py
|___________ reader_SG.py
So there will be the main functionality still in pubholidays.py, however all the country readers will all be in the plugins package (and subdirectory).
But first, let’s install the pyplugs library
Installing pyplugs
PyPlugs is available at PyPI. You can install it using pip:
python -m pip install pyplugs
Or, using pip directly:
pip install pyplugs
Pyplugs is composed of three levels:
- Plug-in packages: Directories containing files with plug-ins
- Plug-ins: Modules containing registered functions or classes
- Plug-in functions: Several registered functions in the same file
Core logic in plugin architecture
The core logic will be simplified to the following:
#pubholiday_pi.py
import argparse
import requests, re
import plugins
G_COUNTRIES = ['UK', 'SG']
def get_working_days(args):
return plugins.read( 'reader_' + args.countrycode)
def setup_args():
parser = argparse.ArgumentParser(description='Get list of public holidays in a given year')
parser.add_argument('-c', '--countrycode', required=True, type=str, choices=G_COUNTRIES, help='Country code')
return parser
if __name__ == '__main__':
parser = setup_args()
args = parser.parse_args()
print( get_working_days(args) )
Now the get_working_days() function has been significant simplified. It calls the “read” function from the plugins/__init__.py package file. The ‘reader_’ + args.countrycode refers to the function and the module name.
Plugin logic
The plugsin/__init__.py is setup as follows:
# plugins/__init__.py
# Import the pyplugs libs
import pyplugs
# All function names are going to be stored under names
names = pyplugs.names_factory(__package__)
# When read function is called, it will call a function received as parameter
read = pyplugs.call_factory(__package__)
The “read” is the same “read” that is referenced by get_working_days() function from the main pubholiday_pi.py files.
The plugin files/functions are each to be stored in files called “reader_<country code>.py”. The following is the UK file:
#plugins/reader_UK.py
import re, requests
import pyplugs
@pyplugs.register
def reader_UK():
r = requests.get('https://www.jalanow.com/singapore-holidays-2021.htm')
m = re.findall('<td class\=\"crDate\">(.+?)<\/td>', r.text)
return list(set(m))
And then finally the SG file:
#plugins/reader_SG.py
import re, requests
import pyplugs
@pyplugs.register
def reader_SG():
r = requests.get('https://www.jalanow.com/singapore-holidays-2021.htm')
m = re.findall('<td class\=\"crDate\">(.+?)<\/td>', r.text)
return list(set(m))
In Conclusion
So there is no change when you run the application – you still get the same output:

However, you have a much more maintainable application.
So we started with a monolithic file, and now we extended this to a plugin architecture where the variations are all stored in the “plugins/” folder. In order to add more country public holidays where the data may come from different websites, all that needs to be done is to: (1) add the country code into variable G_COUNTRIES to ensure the command line argument validation works, and (2) add the new file called reader_<country code>.py in the plugins directory with a function name also called reader_<country code>(). That’s it, everything else will work.
You can also see how we used importlib to achieve a similar outcome as well: A plugin architecture using importlib.
Get Notified Automatically Of New Articles
How To Build REST APIs with Python and Starlette
Intermediate
You need a lightweight REST API in Python — fast, async, and without the learning curve of a full framework. FastAPI is popular but it adds complexity you may not need for smaller services. Starlette is the ASGI toolkit that powers FastAPI, and it is remarkably capable on its own: routing, middleware, WebSockets, background tasks, and a built-in test client — all in a few hundred lines of framework code.
Install Starlette and an ASGI server with pip install starlette uvicorn. You also need pip install httpx to use the built-in test client. Everything else in this article uses Starlette’s standard library.
In this article you will learn how to define routes, handle path and query parameters, parse JSON request bodies, add middleware, use background tasks, and test your API with Starlette’s TestClient — all without FastAPI.
Starlette REST API: Quick Example
Here is the smallest complete Starlette API — one route that returns JSON:
# hello_api.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
import uvicorn
async def homepage(request):
return JSONResponse({"message": "Hello from Starlette!", "version": "1.0"})
app = Starlette(routes=[
Route("/", homepage),
])
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)
Run it:
python hello_api.py
Test with curl:
curl http://127.0.0.1:8000/
{"message": "Hello from Starlette!", "version": "1.0"}
Every Starlette route is an async function that receives a Request object and returns a Response. The Route object binds a URL path to a handler. The Starlette application wires it all together into an ASGI-compatible callable that uvicorn serves.
What Is Starlette and Why Use It?
Starlette is a lightweight ASGI framework — ASGI stands for Asynchronous Server Gateway Interface, Python’s async equivalent of WSGI. Where Flask uses WSGI (synchronous), Starlette uses ASGI, which means your handlers run as coroutines and can await database queries, HTTP calls, and file I/O without blocking other requests.
| Framework | Interface | Validation | Size | Best For |
|---|---|---|---|---|
| Flask | WSGI (sync) | Manual | Small | Simple sync APIs, prototypes |
| Django REST | WSGI/ASGI | Serializers | Large | Full-stack Django apps |
| FastAPI | ASGI (async) | Pydantic auto | Medium | APIs with auto-generated docs |
| Starlette | ASGI (async) | Manual | Tiny | Minimal async APIs, library base |
Choose Starlette when you want full async support with minimal overhead and maximum control. FastAPI is built directly on Starlette — understanding Starlette makes you a better FastAPI developer too.
Routes, Path Parameters, and Query Parameters
Starlette routes support path parameters using curly-brace syntax. Query parameters are read from request.query_params. Here is a mini products API demonstrating both:
# products_api.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
PRODUCTS = {
1: {"name": "Widget A", "price": 9.99, "category": "widgets"},
2: {"name": "Gadget B", "price": 24.99, "category": "gadgets"},
3: {"name": "Widget C", "price": 14.99, "category": "widgets"},
}
async def list_products(request):
category = request.query_params.get("category")
if category:
filtered = {k: v for k, v in PRODUCTS.items() if v["category"] == category}
return JSONResponse({"products": filtered, "count": len(filtered)})
return JSONResponse({"products": PRODUCTS, "count": len(PRODUCTS)})
async def get_product(request):
product_id = int(request.path_params["product_id"])
product = PRODUCTS.get(product_id)
if product is None:
return JSONResponse({"error": "Product not found"}, status_code=404)
return JSONResponse({"id": product_id, **product})
app = Starlette(routes=[
Route("/products", list_products),
Route("/products/{product_id:int}", get_product),
])
Output (GET /products?category=widgets):
{"products": {"1": {...}, "3": {...}}, "count": 2}
Output (GET /products/99):
{"error": "Product not found"} (HTTP 404)
Path parameters use converters: {product_id:int} automatically converts the segment to an integer and returns HTTP 422 if the segment is not numeric. Other converters include :str (default), :float, and :path (matches slashes too).
Parsing JSON Request Bodies
POST and PUT handlers read the request body with await request.json(). This is fully async — it does not block while waiting for the client to finish sending data.
# create_product.py (add to products_api.py)
from starlette.routing import Route, Router
next_id = 4
async def create_product(request):
global next_id
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
name = body.get("name", "").strip()
price = body.get("price")
if not name or price is None:
return JSONResponse({"error": "name and price are required"}, status_code=422)
product = {"name": name, "price": float(price), "category": body.get("category", "general")}
PRODUCTS[next_id] = product
result = {"id": next_id, **product}
next_id += 1
return JSONResponse(result, status_code=201)
Test with curl:
curl -X POST http://127.0.0.1:8000/products -H "Content-Type: application/json" -d '{"name": "Super Gadget", "price": 49.99, "category": "gadgets"}'
{"id": 4, "name": "Super Gadget", "price": 49.99, "category": "gadgets"}
Always validate the body before using it. body.get() returns None for missing fields rather than raising KeyError. Return HTTP 422 for semantic validation errors (wrong types, missing fields) and HTTP 400 for parse errors (malformed JSON).
Adding Middleware
Middleware in Starlette wraps every request. Use it for logging, authentication headers, CORS, and timing. Here is a simple request logger:
# middleware_example.py
import time
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Route
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
response.headers["X-Process-Time"] = f"{elapsed_ms:.2f}ms"
print(f"{request.method} {request.url.path} -- {response.status_code} in {elapsed_ms:.2f}ms")
return response
async def root(request):
return JSONResponse({"ok": True})
app = Starlette(
routes=[Route("/", root)],
middleware=[Middleware(TimingMiddleware)],
)
Console output on each request:
GET / -- 200 in 0.42ms
Middleware receives the request before the handler runs (call_next forwards it) and gets the response before it is sent to the client. This lets you inspect and modify both. For production, use Starlette’s built-in CORSMiddleware, SessionMiddleware, and GZipMiddleware from starlette.middleware.cors etc.
Testing with TestClient
Starlette ships with a synchronous test client built on httpx. You can write standard pytest tests without starting a server:
# test_products.py
from starlette.testclient import TestClient
# assumes products_api.py with app defined
from products_api import app
client = TestClient(app)
def test_list_all_products():
resp = client.get("/products")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 3
def test_filter_by_category():
resp = client.get("/products?category=widgets")
assert resp.status_code == 200
assert resp.json()["count"] == 2
def test_get_product_not_found():
resp = client.get("/products/999")
assert resp.status_code == 404
assert "error" in resp.json()
Run tests:
pytest test_products.py -v
Output:
test_products.py::test_list_all_products PASSED
test_products.py::test_filter_by_category PASSED
test_products.py::test_get_product_not_found PASSED
The TestClient handles the event loop internally, so you can use it in regular synchronous test functions. For async test functions, use pytest-anyio or pytest-asyncio with AsyncClient from httpx.
Real-Life Example: Task Manager API
Here is a complete CRUD API for a task manager — list, create, update, and delete tasks — with validation, proper status codes, and a test suite.
# tasks_api.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
tasks = {}
_next_id = 1
async def list_tasks(request):
status = request.query_params.get("status")
items = list(tasks.values())
if status:
items = [t for t in items if t["status"] == status]
return JSONResponse({"tasks": items, "count": len(items)})
async def create_task(request):
global _next_id
body = await request.json()
title = (body.get("title") or "").strip()
if not title:
return JSONResponse({"error": "title is required"}, status_code=422)
task = {"id": _next_id, "title": title, "status": "pending"}
tasks[_next_id] = task
_next_id += 1
return JSONResponse(task, status_code=201)
async def update_task(request):
task_id = int(request.path_params["task_id"])
if task_id not in tasks:
return JSONResponse({"error": "Task not found"}, status_code=404)
body = await request.json()
if "status" in body:
tasks[task_id]["status"] = body["status"]
if "title" in body:
tasks[task_id]["title"] = body["title"].strip()
return JSONResponse(tasks[task_id])
async def delete_task(request):
task_id = int(request.path_params["task_id"])
if task_id not in tasks:
return JSONResponse({"error": "Task not found"}, status_code=404)
deleted = tasks.pop(task_id)
return JSONResponse({"deleted": deleted})
app = Starlette(routes=[
Route("/tasks", list_tasks, methods=["GET"]),
Route("/tasks", create_task, methods=["POST"]),
Route("/tasks/{task_id:int}", update_task, methods=["PUT"]),
Route("/tasks/{task_id:int}", delete_task, methods=["DELETE"]),
])
This covers the four REST operations in about 40 lines. The same pattern scales to database-backed APIs — replace the in-memory tasks dict with await db.fetch_all() calls using databases or SQLAlchemy async.
Frequently Asked Questions
Should I use Starlette or FastAPI?
Use FastAPI if you want automatic request validation with Pydantic, auto-generated OpenAPI documentation, and dependency injection. Use Starlette if you want the smallest possible dependency footprint, full control over request handling, or you are building a library rather than an application. FastAPI adds roughly 200 lines of code on top of Starlette, so the trade-off is clear: FastAPI saves boilerplate at the cost of one more dependency.
What is uvicorn and do I need it?
Uvicorn is the ASGI server that actually listens on the TCP port and passes requests to your Starlette app. It is not part of Starlette itself — ASGI separates the server from the framework. For production, use gunicorn -k uvicorn.workers.UvicornWorker to run multiple uvicorn workers with automatic restart on crash.
How do I add global error handling?
Pass an exception_handlers dict to Starlette(): exception_handlers={404: not_found_handler, 500: server_error_handler}. Each handler is an async function with the same signature as a route handler. You can also use HTTPException from starlette.exceptions inside route handlers and catch it globally.
Can Starlette serve static files?
Yes. Use Mount("/static", StaticFiles(directory="static"), name="static") in your routes. Mount mounts any ASGI app (including StaticFiles) at a URL prefix. This is how you serve CSS, JavaScript, and image files alongside your API routes.
Does Starlette support WebSockets?
Yes, and it is first-class. Use WebSocketRoute("/ws", ws_handler) in your routes. The handler receives a WebSocket object; call await ws.accept(), then await ws.send_text() and await ws.receive_text() in a loop. Background tasks are also built in via BackgroundTask from starlette.background.
Conclusion
Starlette gives you a complete async web toolkit in under 1,000 lines of framework code. You covered routing with path and query parameters, async JSON body parsing, middleware for logging and timing, and the TestClient for fast integration tests. The task manager API ties it all together in a realistic CRUD design.
Extend the task manager by adding a databases connection (async PostgreSQL or SQLite) and replacing the in-memory dict — the route handlers do not need to change, only the data access layer. That separation is Starlette’s design philosophy: give you the primitives, let you build the rest.
For the full API reference, see the Starlette documentation.
Related Articles
Further Reading: For more details, see the Python importlib documentation.
Frequently Asked Questions
What is a plugin architecture in Python?
A plugin architecture allows you to extend an application’s functionality by loading external code modules at runtime without modifying the core application. It promotes loose coupling, making your software more flexible and maintainable.
How does PyPlugs work?
PyPlugs provides a simple decorator-based system for registering and discovering plugins. You decorate functions or classes with PyPlugs decorators, and the framework automatically discovers and loads them from specified packages or directories.
What are alternatives to PyPlugs for plugin systems in Python?
Alternatives include pluggy (used by pytest), stevedore (uses setuptools entry points), yapsy, and Python’s built-in importlib for manual plugin loading. Each has different tradeoffs in complexity and features.
When should I use a plugin architecture?
Use a plugin architecture when you need extensibility without modifying core code, when third parties should be able to add features, or when different deployments need different feature sets. Common examples include text editors, web frameworks, and data processing pipelines.
Can I create a simple plugin system without external libraries?
Yes. Use Python’s importlib.import_module() to dynamically load modules from a plugins directory, combined with a registration pattern using decorators or base classes. This gives you a basic but functional plugin system with no dependencies.