You’ve written some Python and now you want other programs to talk to it over the web. An API does exactly that, and it takes less code than most people expect.
Quick answer: this FastAPI tutorial for beginners builds a working order API in one file. Create a FastAPI() app, decorate plain functions with @app.get or @app.post, then start it with fastapi dev main.py.
from fastapi import FastAPI
app = FastAPI(title="Order Tracking API")
@app.get("/")
def home():
return {"message": "Order Tracking API is running"}
We built and ran every step below on Python 3.12.5 and FastAPI 0.162.2, against a real local server. The running example is a small order tracker for a US online store.
What You Need Before You Start
- Python 3.10 or newer, because the code uses the
str | Nonetype syntax. - Basic Python: functions, dictionaries and lists.
If you’ve never set up an environment, managing several Python versions covers the groundwork. An API here simply means URLs that return JSON instead of web pages.
Step 1: Install FastAPI in a Virtual Environment
Make a folder, create a virtual environment inside it and install FastAPI with its standard extras. The extras bring the fastapi command and the Uvicorn server:
mkdir order-api
cd order-api
python -m venv .venv
.venv\Scripts\activate
pip install "fastapi[standard]"
On macOS or Linux, activate with source .venv/bin/activate. If an import fails afterward, this ModuleNotFoundError fix usually explains why.
Step 2: Create and Run a FastAPI App
Save the code below as main.py. The app object is your API, and @app.get("/") tells FastAPI to call home() whenever someone requests the root URL. Whatever dictionary you return becomes JSON.
from fastapi import FastAPI
app = FastAPI(title="Order Tracking API")
@app.get("/")
def home():
return {"message": "Order Tracking API is running"}
Output:
Server started at http://127.0.0.1:8000
Documentation at http://127.0.0.1:8000/docs
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Application startup complete.

fastapi dev starts the server and reloads it each time you save.Open http://127.0.0.1:8000 in a browser and you’ll see the JSON:

home(), returned as JSON.Weighing an API against other project types? Building apps with Python compares them.
Step 3: Add a GET Endpoint That Lists Orders
Same file, now with data. We’ll keep three orders in a plain list, since a database would only distract from the API itself:
ORDERS = [
{"id": 1001, "customer": "Megan Carter", "city": "Austin, TX",
"total": 84.50, "status": "shipped"},
{"id": 1002, "customer": "Tyler Brooks", "city": "Denver, CO",
"total": 129.99, "status": "processing"},
{"id": 1003, "customer": "Hannah Reed", "city": "Boston, MA",
"total": 42.25, "status": "delivered"},
]
@app.get("/orders")
def list_orders(status: str | None = None):
if status:
return [o for o in ORDERS if o["status"] == status]
return ORDERS
Any function argument that isn’t in the URL path becomes a query parameter, so status is read from ?status=shipped. This script calls the running server with httpx, which the standard extras install:
import httpx
BASE = "http://127.0.0.1:8000"
orders = httpx.get(f"{BASE}/orders").json()
print(len(orders), "orders")
for order in orders:
print(order["id"], order["customer"], order["status"])
shipped = httpx.get(f"{BASE}/orders", params={"status": "shipped"})
print(shipped.url)
print(shipped.json())
Output:
3 orders
1001 Megan Carter shipped
1002 Tyler Brooks processing
1003 Hannah Reed delivered
http://127.0.0.1:8000/orders?status=shipped
[{'id': 1001, 'customer': 'Megan Carter', 'city': 'Austin, TX', 'total': 84.5, 'status': 'shipped'}]

That returns 3 orders, and the filter narrows them to Megan Carter’s $84.50 order. The filter itself is a compact Python for loop.
Step 4: Get One Order by ID
Curly braces in the route create a path parameter. The int type hint isn’t decoration: FastAPI converts the URL text to an integer and rejects anything else. Add HTTPException to your import, then this route:
@app.get("/orders/{order_id}")
def get_order(order_id: int):
for order in ORDERS:
if order["id"] == order_id:
return order
raise HTTPException(status_code=404, detail="Order not found")
import httpx
BASE = "http://127.0.0.1:8000"
for path in ("/orders/1002", "/orders/9999", "/orders/abc"):
response = httpx.get(BASE + path)
print(path, "->", response.status_code)
print(response.json())
Output:
/orders/1002 -> 200
{'id': 1002, 'customer': 'Tyler Brooks', 'city': 'Denver, CO', 'total': 129.99, 'status': 'processing'}
/orders/9999 -> 404
{'detail': 'Order not found'}
/orders/abc -> 422
{'detail': [{'type': 'int_parsing', 'loc': ['path', 'order_id'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'abc'}]}

Order 1002 comes back with a 200, 9999 gets our 404, and abc gets a 422 that we never had to code. Type hints drive all of this; checking variable types in Python is a useful refresher.
Step 5: Accept New Orders With POST and Pydantic
A Pydantic model is a class describing the JSON you’ll accept. Fields without a default are required, and Field(gt=0) rejects a zero or negative total. Here’s the complete main.py with those changes. I dropped the Step 2 home route because this API doesn’t need it:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="Order Tracking API")
ORDERS = [
{"id": 1001, "customer": "Megan Carter", "city": "Austin, TX",
"total": 84.50, "status": "shipped"},
{"id": 1002, "customer": "Tyler Brooks", "city": "Denver, CO",
"total": 129.99, "status": "processing"},
{"id": 1003, "customer": "Hannah Reed", "city": "Boston, MA",
"total": 42.25, "status": "delivered"},
]
class NewOrder(BaseModel):
customer: str = Field(examples=["Jordan Hayes"])
city: str = Field(examples=["Seattle, WA"])
total: float = Field(gt=0, examples=[59.75])
@app.get("/orders")
def list_orders(status: str | None = None):
if status:
return [o for o in ORDERS if o["status"] == status]
return ORDERS
@app.get("/orders/{order_id}")
def get_order(order_id: int):
for order in ORDERS:
if order["id"] == order_id:
return order
raise HTTPException(status_code=404, detail="Order not found")
@app.post("/orders", status_code=201)
def create_order(new_order: NewOrder):
order = new_order.model_dump()
order["id"] = max(o["id"] for o in ORDERS) + 1
order["status"] = "processing"
ORDERS.append(order)
return order

Because create_order takes a NewOrder argument, FastAPI reads the request body as JSON, validates it and hands you a typed object. Let’s send one good order and two bad ones:
import httpx
BASE = "http://127.0.0.1:8000"
good = {"customer": "Jordan Hayes", "city": "Seattle, WA", "total": 59.75}
no_city = {"customer": "Ava Nguyen", "total": 18.40}
bad_total = {"customer": "Ava Nguyen", "city": "Tampa, FL", "total": -5}
for body in (good, no_city, bad_total):
response = httpx.post(f"{BASE}/orders", json=body)
print(response.status_code, response.json())
print(len(httpx.get(f"{BASE}/orders").json()), "orders now")
Output:
201 {'customer': 'Jordan Hayes', 'city': 'Seattle, WA', 'total': 59.75, 'id': 1004, 'status': 'processing'}
422 {'detail': [{'type': 'missing', 'loc': ['body', 'city'], 'msg': 'Field required', 'input': {'customer': 'Ava Nguyen', 'total': 18.4}}]}
422 {'detail': [{'type': 'greater_than', 'loc': ['body', 'total'], 'msg': 'Input should be greater than 0', 'input': -5, 'ctx': {'gt': 0.0}}]}
4 orders now

Only Jordan Hayes’s $59.75 order was saved, so the list now holds 4, since lists are mutable. The loc value in each error names the exact field that failed.