API Development in 2026: Building REST and GraphQL APIs with Python
By Irene Holden
Last Updated: January 15th 2026

Quick Summary
Yes - in 2026 you can build robust REST and GraphQL APIs with Python by using FastAPI for the REST layer and Strawberry for GraphQL, modeling data with Pydantic and publishing OpenAPI/GraphQL schemas so humans and AI agents can reliably consume them; FastAPI’s async/OpenAPI-first design can handle 10,000+ requests per second and GraphQL adoption in enterprises is around 50-60%, making a hybrid stack the pragmatic choice. To be production-ready, pair JWT auth with object-level (BOLA) checks, machine-readable rate limits (API abuses have surged roughly 400% with an estimated $186B in related costs), move from in-memory dicts to PostgreSQL, and add CI/CD, containers, testing, and observability.
Before we start wiring up endpoints, think of this section as clearing space in the utility closet: you’re making sure you’ve got the right tools on the shelf and you know which breaker does what. AI tools can spin up a “working” FastAPI project in seconds now, but they won’t decide which Python version you should run, how to isolate dependencies, or which libraries actually fit your use case. That base setup is still on you.
Check your skills first
You don’t need to be a senior engineer, but you should be comfortable with a few basics so the rest of this guide feels like plugging in new smart bulbs, not rewiring from scratch. Make sure you can already do things like write simple Python functions and classes, navigate the command line, and understand core HTTP ideas such as GET vs POST, headers, and status codes. If SQL and DevOps (things like Docker and CI/CD) feel fuzzy, that’s normal for career switchers - this is exactly the gap structured programs try to close.
For example, Nucamp’s 16-week Back End, SQL and DevOps with Python bootcamp is designed to take beginners from “I can write a script” to “I can build and deploy real APIs” by walking through Python fundamentals, PostgreSQL, CI/CD, Docker, and cloud deployment in a coherent sequence. Whether you learn that way or self-study, the goal is the same: you want enough foundation that when we talk about rate limits or JWTs, you’re not also fighting your editor or the terminal.
Confirm Python and create your project
On your machine, you’ll want Python 3.10+. Check your version in a terminal:
python --version
# or, depending on your OS:
python3 --version
If you see 3.10, 3.11, or 3.12, you’re in good shape. Next, create a dedicated folder and virtual environment so this “smart home” doesn’t leak packages into other projects:
- Create a project directory and move into it:
mkdir smart_home_api cd smart_home_api - Create a virtual environment:
python -m venv .venv - Activate it:
# macOS / Linux source .venv/bin/activate # Windows (PowerShell) .venv\Scripts\Activate.ps1
Pro tip: keep one virtual environment per project. In the same way you don’t want one light switch unexpectedly controlling two rooms, you don’t want one global Python environment controlling every app.
Install FastAPI, GraphQL, and security/testing tools
With the virtual environment active, install the core libraries we’ll lean on throughout the guide:
pip install "fastapi[all]" uvicorn strawberry-graphql[fastapi] \
python-jose[cryptography] passlib[bcrypt] httpx pytest
Here’s what you just added to your toolbox: FastAPI for the main REST layer, Strawberry GraphQL to give clients flexible queries, python-jose and passlib for JWTs and password hashing, and httpx plus pytest for testing. Framework comparison guides like the Python Backend Framework Decision Guide from Rollbar point out that FastAPI’s async-first design and OpenAPI integration make it a top pick when your primary job is building APIs instead of full-stack web apps.
“The consensus recommendation is: FastAPI for APIs, Django for full-stack, Flask for everything else - then optimize only when benchmarks prove you need to.” - Rollbar, Python Backend Framework Decision Guide
Choose an editor and an HTTP client
You’ll also want a comfortable code editor and a way to flip your “API light switches” on and off during development. Use VS Code, PyCharm, or any editor that gives you Python syntax highlighting and virtualenv integration. For calling endpoints, you can use curl in the terminal, or a GUI client like Postman or Insomnia. FastAPI will also give you an interactive docs UI at /docs, which becomes your equivalent of a labeled wall switch panel - each endpoint clearly visible and testable.
Warning: don’t skip the HTTP client setup thinking “I’ll just click around in the browser.” As your API gains auth, rate limits, and background tasks, you’ll need to send headers, body payloads, and different verbs (POST, DELETE, etc.) consistently. Having a real client from day one keeps your testing habits aligned with how mobile apps, dashboards, and AI agents will actually talk to your API.
Steps Overview
- Prerequisites and setup
- Design the API contract
- Create the FastAPI project skeleton
- Model data with Pydantic and OpenAPI
- Implement core REST endpoints
- Add a GraphQL layer for flexible queries
- Secure the API: auth, BOLA, and rate limiting
- Harden the API: errors, versioning, and background tasks
- Test and observe with pytest, httpx, and logging
- Design your API for AI agents and tools
- Next steps: database, CI/CD, and career skills
- Troubleshooting common issues
- Common Questions
Related Tutorials:
Teams planning reliability work will find the comprehensive DevOps, CI/CD, and Kubernetes guide particularly useful.
Design the API contract
Before you start screwing in smart bulbs, you sketch which switch should control which room. Designing your API contract is the same job: deciding your resources, paths, and permissions before any code. In a world where AI tools can now generate “working” FastAPI or Django REST endpoints on demand, this design step is where you add value. That’s why roughly 82% of organizations now follow an API-first approach, defining contracts (usually with OpenAPI) before implementation, as outlined in analyses like CodersLab’s overview of future API trends and best practices.
Start with your rooms, devices, and people
For our smart-home API, treat “rooms” and “devices” as your core resources and “who’s using this” as your clients. You’re mapping the house before pulling any wire. Concretely, define:
- Resources (what you expose):
Room:{ id, name }Device:{ id, room_id, name, type, is_online, owner_id }
- Clients (who flips switches):
- Mobile app
- Admin dashboard
- AI agent that manages energy usage
- Initial REST paths (clear switches):
GET /v1/roomsPOST /v1/devicesGET /v1/devices/{id}
- GraphQL entrypoint (one smart scene controller):
POST /graphqlwith queries like:query { rooms { name devices(onlineOnly: true) { id name type } } }
Sketch the contract before you code
Next, write a minimal, OpenAPI-style sketch of what you just decided. This is your wiring diagram; FastAPI will turn it into a full OpenAPI spec and interactive docs later, but you want the intent captured up front so frontends, mobile apps, and AI agents can rely on it:
# api-contract.yaml (sketch, not full OpenAPI)
basePath: /v1
resources:
Room:
fields: [id, name]
Device:
fields: [id, room_id, name, type, is_online, owner_id]
endpoints:
- GET /rooms
response: [Room]
- POST /devices
body: { room_id, name, type }
responses:
201: Device
422: ValidationError
- GET /devices/{id}
responses:
200: Device
404: NotFound
Modern API design guides, like the eight crucial API design best practices from DocuWriter, recommend exactly this: clear resource definitions, strict status codes (201 for create, 422 for validation, 404 for missing resources), and a contract that tools can turn into docs and SDKs.
“API-first development - treating APIs as first-class products - results in more consistent, reusable, and discoverable interfaces, enhancing efficiency, maintainability, and long-term scalability.” - Fern Engineering Team, API-First Platforms Report
REST vs GraphQL: label your switches
| Aspect | REST | GraphQL | Typical Use |
|---|---|---|---|
| Endpoint style | Many URLs, one per resource/action | Single /graphql endpoint |
Service boundaries vs. client-facing aggregation |
| Adoption | ~93% of teams use it for stable CRUD | Enterprise use has surged past 50-60% | Public APIs vs. complex dashboards/mobile |
| Data shape | Server decides response shape per endpoint | Client asks for exactly what it needs | Predictable flows vs. flexible UIs/AI agents |
| Strategic role | “Undefeated default” for most services | Great for stitching multiple resources | Hybrid setups report higher satisfaction |
Industry surveys show REST is still the dominant style, with roughly 93% of teams using it for stable CRUD and clear service boundaries, while GraphQL adoption in enterprises has climbed above 50-60% for complex, data-hungry UIs and mobile clients. Analyses like IBM’s seven key insights on GraphQL trends note that hybrid setups - REST for internal wiring, GraphQL for flexible client queries - consistently report higher satisfaction than “REST-only” or “GraphQL-only” stacks.
Avoid the usual contract traps
- Use nouns in paths (
/devices), not verbs (/createDevice). - Group by resource, not by client:
/devicesshould work for mobile, dashboards, and AI agents alike. - Don’t design solely for “the React app” and discover later that mobile or agents need completely different shapes.
- Avoid smuggling actions into URLs like
/devices/turnOnwhen HTTP semantics (POST, PATCH) or a/commandsresource is clearer.
Create the FastAPI project skeleton
With the contract sketched, the next step is hanging the actual panel on the wall: a small, clean FastAPI app you can grow without it turning into a rat’s nest of wires. AI can absolutely scaffold this for you now, but the directory layout, entrypoint, and how you expose docs are still architectural choices you need to understand and control.
Set up the project folder and virtual environment
If you haven’t already, isolate this API in its own directory and virtual environment so dependencies don’t bleed across projects. Think of it like giving this “house” its own breaker box.
- Create the project folder and move into it:
mkdir smart_home_api cd smart_home_api - Create a virtual environment:
python -m venv .venv - Activate it:
# macOS / Linux source .venv/bin/activate # Windows (PowerShell) .venv\Scripts\Activate.ps1 - Install the core dependencies:
pip install "fastapi[all]" uvicorn strawberry-graphql[fastapi] \ python-jose[cryptography] passlib[bcrypt] httpx pytest
Pro tip: keep this virtualenv tied to the project directory in your editor (VS Code, PyCharm, etc.) so linting and type hints line up with the exact versions you’re running. That’s especially important with async frameworks like FastAPI, where type support is evolving quickly according to comparisons such as the JetBrains overview of Django, Flask, and FastAPI.
Create a minimal FastAPI app
Now lay down the thinnest possible entrypoint: one file that declares the app, basic metadata, and a health check so you can tell if the service is alive without touching any business logic.
- Create the app package and main module:
mkdir app touch app/init.py app/main.py - Put this in
app/main.py:from fastapi import FastAPI app = FastAPI( title="Smart Home API", version="1.0.0", description="REST + GraphQL API for rooms and devices in a smart home." ) @app.get("/health", tags=["health"]) async def health_check(): return {"status": "ok"}
Warning: keep this file lean. Route registration and cross-cutting concerns (middleware, exception handlers) live here; business logic, database access, and feature-specific routes will move into their own modules so this doesn’t grow into a 1,000-line tangle.
Run the dev server and inspect auto-generated docs
With the app defined, start the development server using Uvicorn, the ASGI server FastAPI relies on for async performance:
uvicorn app.main:app --reload
Open a browser and verify that everything is wired correctly:
http://127.0.0.1:8000/health→{"status": "ok"}http://127.0.0.1:8000/docs→ interactive Swagger UIhttp://127.0.0.1:8000/redoc→ alternative Redoc docs (viafastapi[all])
These docs are generated automatically from your code and type hints, which is a big part of why FastAPI is the go-to choice for high-performance APIs in many framework roundups. Benchmarks in recent backend framework comparisons note that FastAPI, running on an async stack, can handle 10,000+ requests per second and is used in production by companies like OpenAI and Netflix. That combination of speed and first-class OpenAPI support is exactly what modern API-first teams look for when they’re wiring up new services, as highlighted in the FastAPI vs Django REST vs Flask comparison from Ingenious Minds Lab.
Model data with Pydantic and OpenAPI
At this point you’ve hung the panel and flipped the health-check switch; now you need to decide what actually lives on each circuit. Modeling your data with Pydantic is like defining, in detail, what counts as a valid “Room” or “Device” so nobody can accidentally plug a toaster into the thermostat line. This is where your API contract stops being hand-wavy and turns into concrete shapes the code and the docs both respect.
Define clear Pydantic models for your resources
FastAPI leans on Pydantic’s BaseModel to define request and response bodies. These models act as both validation rules and documentation sources. Create app/models.py and describe your smart-home entities explicitly:
from pydantic import BaseModel, Field
from typing import Optional
from uuid import UUID, uuid4
class Room(BaseModel):
id: UUID = Field(default_factory=uuid4)
name: str = Field(..., min_length=1, max_length=100)
class RoomCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class Device(BaseModel):
id: UUID = Field(default_factory=uuid4)
room_id: UUID
name: str = Field(..., min_length=1, max_length=100)
type: str = Field(..., regex="^(light|thermostat|sensor)$")
is_online: bool = True
owner_id: UUID
class DeviceCreate(BaseModel):
room_id: UUID
name: str = Field(..., min_length=1, max_length=100)
type: str = Field(..., regex="^(light|thermostat|sensor)$")
Those Field constraints (min_length, regex, required vs optional) ensure that bad data never gets past the front door of your API. Instead of every endpoint re-checking strings and IDs, FastAPI will validate incoming JSON against these models automatically and return structured 422 errors when something doesn’t match.
Separate input and output: RoomCreate vs Room
Notice there are pairs of models: RoomCreate vs Room, DeviceCreate vs Device. That split is intentional. Clients don’t set IDs or server-computed fields directly, just like guests don’t label your breaker panel; the system does. This keeps you free to evolve response shapes over time without breaking every POST call that already exists.
| Model | Used For | Includes Server Fields? | Example Fields |
|---|---|---|---|
RoomCreate |
Request body when creating a room | No | name |
Room |
Response body when returning a room | Yes | id, name |
DeviceCreate |
Request body when creating a device | No | room_id, name, type |
Device |
Response body when returning a device | Yes | id, owner_id, is_online |
Pro tip: any time you’re tempted to reuse the same model for both input and output, ask yourself whether the client should really be allowed to set all those fields. If the answer is “no,” split the model.
Let FastAPI generate a real OpenAPI spec
Once these models are wired into your endpoints with response_model and typed parameters, FastAPI can generate a full OpenAPI description and interactive docs at /docs and /redoc. That spec is what frontends, mobile apps, and AI agents will use as their blueprint. Effective REST design guides like Contentstack’s REST API design principles emphasize that well-typed contracts and consistent status codes dramatically reduce integration friction across teams.
“Using standards like OpenAPI to describe your services allows developers to integrate faster, with fewer mistakes, because they can rely on a single source of truth for how your API behaves.” - Contentstack Engineering Team, Effective RESTful API Design Principles
To align with those practices, make sure your handlers return the right status codes as well as the right shapes: 201 for successful creations, 422 when validation fails, and 404 when a resource truly doesn’t exist. Avoid the anti-pattern of returning 200 OK for failures and stuffing error details into the body; that confuses both humans and autonomous clients that are trying to reason about your API like a labeled breaker panel, not a mystery switch.
Implement core REST endpoints
Once your data models are in place, it’s time to wire up the everyday switches: predictable REST endpoints for rooms and devices. This is the part AI tools are very good at auto-generating, but the details you choose - how you group routes, which status codes you return, and how thin you keep each handler - determine whether your API feels like a clean switch panel or that one random switch that sometimes kills power to half the house.
Split routers by resource
Start by grouping endpoints by resource using FastAPI’s APIRouter. That keeps /v1/rooms and /v1/devices on their own “circuits” so you can scale and secure them independently later:
- Create router files:
mkdir app/routers touch app/routers/init.py app/routers/rooms.py app/routers/devices.py - Implement
app/routers/rooms.py:from fastapi import APIRouter, HTTPException, status from typing import List from uuid import UUID from app.models import Room, RoomCreate router = APIRouter(prefix="/v1/rooms", tags=["rooms"]) ROOMS: dict[UUID, Room] = {} @router.get("", response_model=List[Room]) async def list_rooms(): return list(ROOMS.values()) @router.post("", response_model=Room, status_code=status.HTTP_201_CREATED) async def create_room(room_in: RoomCreate): room = Room(name=room_in.name) ROOMS[room.id] = room return room @router.get("/{room_id}", response_model=Room) async def get_room(room_id: UUID): room = ROOMS.get(room_id) if not room: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found") return room - Wire routers into
app/main.py:from fastapi import FastAPI from app.routers import rooms, devices app = FastAPI(title="Smart Home API", version="1.0.0") app.include_router(rooms.router) app.include_router(devices.router)
Use HTTP verbs and status codes as your contract
Each route should do one clear thing, and its HTTP method plus status code should advertise that clearly to humans and tools alike. Modern REST design guides, like Postman’s discussion of GraphQL vs REST and when to use each, stress that consistent use of verbs and status codes is what lets teams integrate quickly without constantly checking docs.
| Method | Example Path | Responsibility | Typical Success Code |
|---|---|---|---|
GET |
/v1/rooms, /v1/devices/{id} |
Retrieve resources without side effects | 200 OK |
POST |
/v1/rooms, /v1/devices |
Create new resources | 201 Created |
DELETE |
/v1/devices/{id} |
Remove resources | 204 No Content |
GET (not found) |
/v1/devices/{id} |
Signal missing resource | 404 Not Found |
Pro tip: don’t return 200 OK for validation errors or missing objects. Use 422 Unprocessable Entity when the body doesn’t match your Pydantic model, and 404 when the ID is valid but the resource isn’t there. Those distinctions are how clients - and AI agents - know whether to retry, fix their payload, or stop.
Keep handlers thin and predictable
As you flesh out app/routers/devices.py with list_devices, create_device, get_device, and delete_device, keep business logic out of the route functions. Each handler should feel like a light switch: receive validated data, call into a service or repository, and return a well-typed response. Longer-term, this separation is what lets you swap an in-memory store for PostgreSQL or add caching without rewriting every endpoint.
“The most important rule of API design is consistency. Developers rely on patterns to work quickly - if every endpoint behaves slightly differently, they’re forced back to the documentation for even simple tasks.” - Anton Martyniuk, REST API Best Practices in 2025, LinkedIn
In practice, that means: one resource per router, one responsibility per endpoint, and no surprises in method or status code. AI can write the boilerplate, but you’re the one who decides which “switch” does what - and whether turning on the kitchen lights ever mysteriously shuts off the living room.
Add a GraphQL layer for flexible queries
REST gives you clearly labeled switches for each room; GraphQL is more like a “scene controller” that lets clients say, “Show me all rooms and just the online devices, with these three fields, in one shot.” When dashboards, mobile apps, and AI agents start asking for cross-cutting views of your data, that flexibility saves a lot of round-trips and over-fetching. Most teams aren’t replacing REST with GraphQL; they’re adding a GraphQL layer on top of solid REST wiring so clients can shape responses without you creating a new endpoint for every variation.
When a GraphQL layer actually helps
GraphQL shines when clients need to stitch together multiple resources with different shapes: think an admin dashboard that shows rooms, devices, and their online status in a single view, or an AI agent that needs just enough data to make a decision without downloading entire objects. Enterprise case studies, like IBM’s overview of GraphQL trends and adoption, describe how organizations use GraphQL to tame increasingly interconnected data models without turning their REST APIs into a sprawling mess of one-off endpoints.
| Pattern | Description | Strengths | Trade-offs |
|---|---|---|---|
| REST-only | All clients talk directly to REST endpoints | Simple, mature tooling, easy caching | Over/under-fetching, many round-trips for complex UIs |
| GraphQL-only | Everything goes through /graphql |
Flexible queries, single endpoint | Harder for internal service boundaries and caching |
| Hybrid (recommended) | REST for services, GraphQL as aggregation layer | Best of both: stable wiring + flexible queries | More moving parts to secure and monitor |
For our smart-home API, we’ll take the hybrid route: keep REST for predictable CRUD (/v1/rooms, /v1/devices) and add GraphQL as a read-focused layer that lets clients query rooms and devices in one call with just the fields they need.
Wire Strawberry GraphQL into FastAPI
To add a GraphQL endpoint without blowing up your existing structure, you’ll define a Strawberry schema that wraps your existing in-memory data and then mount it in FastAPI. First, create app/graphql_schema.py:
import strawberry
from typing import List
from uuid import UUID
from app.routers.rooms import ROOMS
from app.routers.devices import DEVICES
from app.models import Room as RoomModel, Device as DeviceModel
@strawberry.type
class Device:
id: strawberry.ID
room_id: strawberry.ID
name: str
type: str
is_online: bool
@strawberry.type
class Room:
id: strawberry.ID
name: str
@strawberry.field
def devices(self, online_only: bool | None = None) -> List[Device]:
devices = [
d for d in DEVICES.values()
if d.room_id == UUID(str(self.id))
]
if online_only is True:
devices = [d for d in devices if d.is_online]
return [
Device(
id=str(d.id),
room_id=str(d.room_id),
name=d.name,
type=d.type,
is_online=d.is_online,
)
for d in devices
]
@strawberry.type
class Query:
@strawberry.field
def rooms(self) -> List[Room]:
return [Room(id=str(r.id), name=r.name) for r in ROOMS.values()]
schema = strawberry.Schema(query=Query)
Then, mount this schema in your FastAPI app by adding a GraphQL router in app/main.py:
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter
from app.routers import rooms, devices
from app.graphql_schema import schema
app = FastAPI(
title="Smart Home API",
version="1.0.0",
)
app.include_router(rooms.router)
app.include_router(devices.router)
graphql_app = GraphQLRouter(schema)
app.include_router(graphql_app, prefix="/graphql")
Restart your server and visit http://127.0.0.1:8000/graphql. You’ll get an in-browser GraphQL IDE where you can run queries like:
query {
rooms {
id
name
devices(onlineOnly: true) {
id
name
type
isOnline
}
}
}
Treat GraphQL as a read-optimized aggregator
The key design move here is that your GraphQL resolvers don’t talk directly to a database; they call into the same data sources and business logic your REST handlers use. That keeps authorization, validation, and side effects in one place instead of duplicating them. Commerce platforms like Shopify describe in their article on GraphQL vs REST for enterprise commerce how they used GraphQL to unify complex inventory and storefront data without abandoning the REST services that already powered critical operations.
Follow that lead: let REST define and protect the wiring (auth, BOLA checks, rate limits), and let GraphQL sit on top as a read-optimized view that stitches together rooms and devices in whatever combinations your dashboards, mobile apps, and AI agents need. That way, adding a new field to a GraphQL query feels like adding a new “scene” in your smart home - not ripping out and re-running all the electrical.
Secure the API: auth, BOLA, and rate limiting
Leaving your API unauthenticated today is like leaving the breaker panel unlocked in the hallway with every switch labeled “DO NOT TOUCH.” API attacks now hit nearly all organizations, with one analysis reporting a 400% surge in API abuses and an estimated $186B in global costs, and calling out object-level flaws as a major factor, according to ByteIota’s 2026 API security overview. AI tools can generate FastAPI code with JWTs in seconds, but they don’t understand your risk profile, your tenants, or what “owning a device” should mean. That’s on you: authentication, object-level authorization (BOLA), and rate limiting are the hidden wiring that keeps your smart-home API from catching fire.
Implement JWT-based auth (OAuth 2.1-style)
First you need to know who’s flipping the switch. A practical pattern is OAuth 2.1-style bearer tokens backed by JWTs: clients hit a token endpoint, get a short-lived access token, and send it in the Authorization: Bearer <token> header on each request. In FastAPI, that looks like this in app/auth.py:
from datetime import datetime, timedelta, timezone
from uuid import UUID
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
SECRET_KEY = "super-secret-change-me"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
async def get_current_user_id(token: str = Depends(oauth2_scheme)) -> UUID:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
sub = payload.get("sub")
if sub is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return UUID(sub)
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials")
def create_access_token(user_id: UUID, expires_delta: Optional[timedelta] = None) -> str:
to_encode = {"sub": str(user_id)}
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
Then add a simple token endpoint in app/main.py (for now, hardcoding a demo user):
from uuid import uuid4
from fastapi import Depends
from fastapi.security import OAuth2PasswordRequestForm
from app.auth import create_access_token
DEMO_USER_ID = uuid4()
@app.post("/v1/token", tags=["auth"])
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
# DEMO ONLY: accept any username/password
access_token = create_access_token(DEMO_USER_ID)
return {"access_token": access_token, "token_type": "bearer"}
“OAuth 2.1 consolidates the best practices of 2.0 - PKCE, secure defaults, and safer flows - so you don’t have to bolt security on later.” - Ricardo Gutierrez, OAuth 2.1 Features You Can’t Ignore in 2026, Medium
Enforce object-level authorization (BOLA protection)
JWTs tell you who the user is; BOLA checks decide what they’re allowed to touch. Every time a client hits /v1/devices/{id}, you must verify the device actually belongs to the authenticated user. A simple helper in app/routers/devices.py makes that explicit:
from fastapi import HTTPException, status, Depends
from uuid import UUID
from app.auth import get_current_user_id
DEVICES: dict[UUID, Device] = {}
def _get_owned_device(device_id: UUID, user_id: UUID) -> Device:
device = DEVICES.get(device_id)
if not device:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found")
if device.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return device
@router.get("/{device_id}", response_model=Device)
async def get_device(
device_id: UUID,
current_user_id: UUID = Depends(get_current_user_id),
):
return _get_owned_device(device_id, current_user_id)
Warning: authentication alone doesn’t stop BOLA. If you only check “is the token valid?” and skip “does this user own this device?”, any authenticated user can start flipping switches in someone else’s house.
Add rate limiting with 429 Too Many Requests
Finally, protect your circuits from surges - both buggy clients and hostile traffic - by adding basic rate limiting that returns 429 Too Many Requests when clients exceed a threshold. For a starter implementation, use an in-memory sliding window keyed by user or IP in app/rate_limit.py:
import time
from collections import defaultdict
from fastapi import Request, HTTPException, status
WINDOW_SECONDS = 60
MAX_REQUESTS = 30 # per key per minute
REQUEST_LOGS = defaultdict(list)
async def rate_limiter(request: Request, key: str | None = None):
ident = key or request.client.host
now = time.time()
# Drop old entries
REQUEST_LOGS[ident] = [ts for ts in REQUEST_LOGS[ident] if now - ts < WINDOW_SECONDS]
if len(REQUEST_LOGS[ident]) >= MAX_REQUESTS:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Try again later.",
)
REQUEST_LOGS[ident].append(now)
Then apply it to hot endpoints, passing the current user ID when available so limits are per-user, not per-IP:
@router.get("", response_model=list[Device])
async def list_devices(
request: Request,
current_user_id: UUID = Depends(get_current_user_id),
):
await rate_limiter(request, key=str(current_user_id))
return [d for d in DEVICES.values() if d.owner_id == current_user_id]
Pro tip: this in-memory version is fine for a single-instance dev setup. In production, move the counters into Redis or another shared store and formalize your policy (e.g., “100 requests per minute per user”), documenting it so human clients and AI agents can back off gracefully instead of hammering your API until it trips the breaker.
Harden the API: errors, versioning, and background tasks
Now that your basic switches work, it’s time to label the breaker panel and add surge protectors. Hardening your API means three things: returning consistent errors instead of mystery 200s, versioning so you can evolve without shocking existing clients, and pushing heavy work into background tasks so a single command doesn’t dim the whole house. This is exactly the layer that API design guides call out as the difference between a “works on my machine” prototype and a service that can safely serve mobile apps, dashboards, and AI agents at scale, as emphasized in Hakia’s API design best practices for 2026.
Standardize error responses and status codes
Start by defining a shared error shape and a global exception handler so every failure looks the same to clients. In app/models.py add:
from pydantic import BaseModel
class APIError(BaseModel):
code: str
message: str
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from fastapi.requests import Request
from app.models import APIError
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content=APIError(
code="HTTP_ERROR",
message=exc.detail if isinstance(exc.detail, str) else "Error",
).dict(),
)
- Always use the right code: 4xx for client issues, 5xx for server bugs.
- Stop returning
200 OKfor failures; treat that as a bug. - Document your
codevalues so clients and agents don’t have to parse free-form text.
Version via the URL, not hidden headers
For long-lived APIs, you will eventually need breaking changes. The most widely adopted pattern is to version in the URL path, e.g. /v1/rooms today and /v2/rooms when you introduce incompatible behavior. A comparison of versioning strategies in Nordic APIs’ deep dive on the State of the API notes that path-based versioning remains the default because it’s explicit, cache-friendly, and easy for both humans and tooling to reason about.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| Path versioning | /v1/devices, /v2/devices |
Clear, debuggable, works well with proxies/CDNs | URL changes when upgrading |
| Header versioning | X-API-Version: 2 |
Keeps URLs stable | Hidden logic, harder to cache and test manually |
| Query param | /devices?version=2 |
Easy to experiment | Can clash with business params, messy caching |
Push heavy work to background tasks with 202 Accepted
Some operations are more like “turn off every light in the building” than “toggle this one lamp.” Instead of blocking the request until everything is done, accept the command, return 202 Accepted, and run the heavy work in the background. With FastAPI, you can start with built-in BackgroundTasks and later move the same logic into Celery, RQ, or a cloud queue:
from fastapi import BackgroundTasks, status, Depends
from uuid import UUID
@app.post("/v1/commands/turn-off-all-lights",
status_code=status.HTTP_202_ACCEPTED)
async def turn_off_all_lights(
background_tasks: BackgroundTasks,
current_user_id: UUID = Depends(get_current_user_id),
):
def do_work():
for device in DEVICES.values():
if device.owner_id == current_user_id and device.type == "light":
device.is_online = False # simplification
background_tasks.add_task(do_work)
return {"status": "accepted"}
“For tasks that take more than a couple of seconds, respond with 202 and offload the work. It keeps your APIs responsive and your clients from guessing whether a timeout means failure.” - Hakia Engineering Team, API Design Best Practices in 2026
Combined, these patterns give you a labeled breaker panel: consistent error shapes and status codes, clear version labels on every circuit, and asynchronous handling for anything that might otherwise trip the main. That’s the level of resilience modern clients and autonomous agents quietly assume when they start relying on your API for real work.
Test and observe with pytest, httpx, and logging
Manually clicking around /docs is like walking through the house flipping every switch after you rewire something; it works once, but it won’t save you the next time someone (or some AI tool) “helpfully refactors” your code. Tests and observability are how you prove the wiring is safe over time: pytest and httpx catch regressions, and logging plus basic metrics tell you when a breaker is about to blow under real traffic.
Write async tests with pytest + httpx
Start by exercising your critical flows with automated tests that call the API just like a real client. Create tests/test_devices.py and use httpx.AsyncClient against your FastAPI app:
import pytest
from httpx import AsyncClient
from uuid import UUID
from app.main import app
from app.auth import create_access_token, DEMO_USER_ID
@pytest.mark.asyncio
async def test_create_and_get_device():
async with AsyncClient(app=app, base_url="http://test") as ac:
token = create_access_token(user_id=DEMO_USER_ID)
headers = {"Authorization": f"Bearer {token}"}
# First create a room
room_resp = await ac.post("/v1/rooms", json={"name": "Living Room"})
assert room_resp.status_code == 201
room = room_resp.json()
# Then create a device
device_resp = await ac.post(
"/v1/devices",
json={
"room_id": room["id"],
"name": "Ceiling Light",
"type": "light",
},
headers=headers,
)
assert device_resp.status_code == 201
device = device_resp.json()
# Retrieve the device
get_resp = await ac.get(f"/v1/devices/{device['id']}", headers=headers)
assert get_resp.status_code == 200
retrieved = get_resp.json()
assert retrieved["name"] == "Ceiling Light"
assert retrieved["room_id"] == room["id"]
pytest
Pro tip: treat tests like circuit labels: start with the most important breakers (auth, BOLA checks, core CRUD), then expand. Security-focused guides such as StackHawk’s API security best practices recommend baking automated checks into every change so you’re not relying on manual spot tests for safety.
Add logging and lightweight metrics
Next, add a simple logging middleware so every request records method, path, status, and latency, plus a timing header your clients can inspect. In app/main.py:
import logging
from time import time
from fastapi import Request
logger = logging.getLogger("smart_home_api")
logging.basicConfig(level=logging.INFO)
@app.middleware("http")
async def log_requests(request: Request, call_next):
start_time = time()
response = await call_next(request)
duration = (time() - start_time) * 1000
logger.info(
"%s %s -> %d in %.2f ms",
request.method,
request.url.path,
response.status_code,
duration,
)
response.headers["X-Process-Time-ms"] = f"{duration:.2f}"
return response
- Use INFO level for high-level request logs; reserve DEBUG for noisy details.
- Never log secrets (JWTs, passwords, API keys) in plaintext.
- Expose timing headers so dashboards and AI agents can spot slow endpoints.
Combine automation and observability
Good teams don’t pick between tests and observability; they combine both so they can catch regressions before deploy and spot anomalies in production. Modern API trend reports, like Capital Numbers’ write-up on top API trends to watch, highlight observability (logs, metrics, traces) and AIOps as core practices for keeping increasingly AI-driven systems reliable.
| Approach | What You Do | What You See | Risk Level |
|---|---|---|---|
| Manual poking | Click around /docs after changes |
Only obvious, happy-path breakage | High |
| Automated tests only | Run pytest on every change | Regression coverage for known flows | Medium |
| Tests + observability | pytest + request logs + basic metrics | Both regressions and real-world anomalies | Low |
“Make testing part of the development workflow by running security scans on every commit and providing actionable results directly in developer tools.” - StackHawk Team, API Security Best Practices: Ultimate Guide
Design your API for AI agents and tools
In this last layer of design, you stop thinking only about human users tapping on screens and start treating AI agents as first-class clients. Those agents will hit your endpoints at odd hours, chain multiple calls together, retry aggressively, and make decisions based entirely on your status codes and response bodies. AI can already generate “working” FastAPI code, but it can’t intuit what a safe retry looks like for your /devices commands or how to distinguish a user error from a system outage - that’s all in how you design the API contract.
Make your API legible to machines
For agents, there’s no “guess what the backend meant.” Everything has to be explicit and consistent so they can build reliable policies on top. That means:
- Using the full range of HTTP status codes: 2xx only for success, 4xx when the client must change something, 5xx when the system is at fault.
- Standardizing your error body, e.g.
{ "code": "RATE_LIMITED", "message": "Rate limit exceeded." }, so tools can branch oncodeinstead of brittle string matching. - Making rate limits machine-readable: return 429 Too Many Requests and expose headers like
X-RateLimit-RemainingandRetry-Afterso agents can back off instead of hammering your service.
Idempotency is another big deal in an AI-driven world. If an agent times out calling a “turn off all lights” endpoint and retries, you don’t want to double-apply the action or accidentally create duplicate resources. Design important POST/PUT operations to accept an Idempotency-Key header and treat repeated requests with the same key as one logical operation. That’s the difference between a smart home that shrugs off flaky Wi-Fi and one where a transient blip leaves half your devices in a weird state.
Lean on schemas and explicit contracts
OpenAPI and GraphQL schemas are how agents “read the house map” without human help. When you keep your REST responses aligned with a well-maintained OpenAPI spec and your GraphQL types tidy, tools can auto-generate clients, validate payloads, and even explore new fields safely. Python has effectively become the glue language for these ecosystems, orchestrating everything from GPUs to cloud APIs - The New Stack describes it as moving toward a kind of “universal AI operating system” in their piece on what’s coming for Python.
“Python has become the ‘high-level assembly language’ of AI, giving developers one syntax to orchestrate heterogeneous hardware, cloud services, and machine learning frameworks.” - Ajay Singh, Python: What’s Coming in 2026, The New Stack
For you as an API designer, that means your FastAPI + GraphQL stack is often the central control plane for a lot of automated behavior. The clearer and more rigorous your contracts are - the types, the nullability rules, the enum values for things like device type - the easier it is for both human teams and autonomous agents to plug in without breaking things.
What this means for your career
From a hiring manager’s point of view, “can spin up a FastAPI endpoint” is now table stakes; AI can already do that on command. What stands out is whether you can design APIs that stay reliable when autonomous agents start chaining calls together, handling errors, and making decisions without human supervision. Articles like How to Build AI Agents in 2026: Stop Coding Like It’s 2024 hammer this point: the real work is orchestrating tools, enforcing guardrails, and modeling workflows that can survive retries, partial failures, and changing schemas.
If you make your API explicit, predictable, and well-documented - down to error codes, rate-limit semantics, and idempotent write operations - you’re not just building “an endpoint.” You’re designing the control surface that both humans and AI systems will rely on to manage your smart-home universe. That wiring diagram thinking is exactly what separates someone who can copy-paste boilerplate from someone who can own a production backend in this AI-heavy landscape.
Next steps: database, CI/CD, and career skills
You’ve wired up a surprisingly capable smart-home API: REST for predictable switches, GraphQL for rich “scenes,” auth and BOLA for safety, rate limits as surge protectors, and tests plus logs to keep an eye on everything. But right now it’s still a demo house sitting on sawhorses: in-memory data, manual deployment, and skills that are solid but not yet “I own this in production.” The next step is turning this into something you could confidently run in the cloud - and talk about in an interview.
Move from in-memory dicts to a real database
First, replace the in-memory ROOMS and DEVICES dicts with a proper database, typically PostgreSQL for backend work. That means designing tables for rooms and devices, writing migrations, and integrating an ORM (like SQLAlchemy) or a query layer that your FastAPI services call instead of raw dicts. This is where your SQL skills become real leverage: you’re deciding indexes, foreign keys, and query patterns that keep the API fast and correct when there are millions of devices, not ten.
| Layer | Current State | Next Step | Key Skill |
|---|---|---|---|
| Data | In-memory dict store |
PostgreSQL with migrations + ORM | SQL + schema design |
| API | Single dev instance | Multiple envs (dev/stage/prod) | Config & environment management |
| Reliability | Manual testing | Automated tests in CI | pytest + CI pipelines |
Add CI/CD, containers, and real deployments
Next, automate the path from “I changed a line of code” to “it’s safely running in the cloud.” That usually looks like a CI pipeline (GitHub Actions, GitLab CI, etc.) that runs your pytest suite on every push, builds a Docker image for the API, and then deploys it to a platform (AWS, Azure, GCP, or a container service) once checks pass. You’re making sure no one can merge a change that breaks auth, BOLA rules, or rate limiting without tests catching it first, and that deployments are boring, repeatable events instead of nerve-wracking rituals.
Turn these skills into a career story
From an employer’s point of view, AI has already commoditized “I can spin up a FastAPI endpoint.” What still stands out is “I can design and operate a secure, observable backend that uses Python, SQL, and DevOps practices to serve real clients and AI agents.” Building that profile on your own is possible but takes discipline, which is why structured paths can help. For example, Nucamp’s 16-week Back End, SQL and DevOps with Python bootcamp combines Python programming, PostgreSQL, CI/CD, Docker, and cloud deployment into a single track, with 10-20 hours per week of work, weekly live workshops capped at 15 students, and early-bird tuition around $2,124 instead of the $10,000+ common at other bootcamps. Add in 5 weeks of data structures and algorithms, career coaching, and a learning community that’s earned roughly a 4.5/5 rating with about 80% five-star reviews, and you get a sense of the bar: in this AI-heavy market, the differentiator isn’t whether you can write Python - it’s whether you can design, ship, and explain resilient APIs backed by solid database and DevOps fundamentals.
Troubleshooting common issues
Even with a solid design, the first time you run real traffic through your API it can feel like that mystery switch that sometimes kills power to the hallway and sometimes does nothing. AI-scaffolded FastAPI code makes it easy to get to “hello world,” but when you hit 401s, random 200s on errors, CORS failures, or sudden 429s, you still need to know how to trace the wiring yourself. This section is a quick field guide to the issues beginners and career-switchers hit most often, and how to fix them systematically.
Auth and permission: 401 vs 403 and “why is everything unauthorized?”
Authentication and authorization failures usually show up as endless 401 Unauthorized or 403 Forbidden responses, even when your token looks correct. In a typical FastAPI + JWT setup, check the following in order:
- 401 but you’re sending a token:
- Verify the header is
Authorization: Bearer <token>, not a custom header name. - Confirm
tokenUrlinOAuth2PasswordBearer(tokenUrl="/v1/token")matches your real login route. - Make sure the JWT
subclaim matches the type yourget_current_user_idexpects (e.g., UUID string).
- Verify the header is
- 403 on objects you “own”:
- Log both
current_user_idanddevice.owner_idbefore the BOLA check to see what’s actually stored. - Check that you set
owner_id=current_user_idwhen creating objects, not a placeholder or random UUID. - Return 403 only when the user is authenticated but not allowed to access that specific object; use 401 when the token is missing/invalid.
- Log both
Validation, 4xx codes, and hidden errors returning 200
Another common failure mode is getting “success” responses that actually contain error messages, or clients seeing 422 when they expected 400. Modern REST guidelines, like the ones discussed in Netguru’s API design best practices, stress using specific 4xx codes and a consistent error shape so clients don’t have to guess. To get there:
- Unexpected 422:
- Compare the request JSON to your Pydantic model: field names, types, required vs optional.
- Use
response_model=YourModelon routes so FastAPI can validate outgoing data too. - Check regex and length constraints in
Field(...)if only some values fail.
- Errors still return 200:
- Search for handlers that manually return
{"error": ...}instead of raisingHTTPException. - Make your global exception handler return the underlying
exc.status_code, not a hard-coded 200. - Adopt a single error schema (e.g.,
APIErrorwithcodeandmessage) and use it everywhere.
- Search for handlers that manually return
CORS, async blocking, and unexpected 429s
Once you add a frontend or external client, you’ll often run into CORS failures, slow responses, or rate limiting that seems to trigger at random. Instead of treating each as a mystery, match the symptom to the likely cause and fix:
| Symptom | Likely Cause | Quick Fix |
|---|---|---|
| Browser says “CORS error” | No CORS middleware or missing origin | Add CORSMiddleware with your frontend origin and allowed methods/headers. |
| Endpoints hang under load | Blocking calls in async routes |
Move DB/HTTP calls to proper async clients or run CPU-heavy work in background tasks. |
| Random 429 Too Many Requests | Rate limiter keyed only by IP or shared env | Key limits by user ID where possible; tune window size and MAX_REQUESTS; exclude internal health checks. |
As security-focused predictions in places like Security Boulevard’s API and AI security outlook point out, more of your traffic will be automated over time. Getting these basics right now - clear auth semantics, precise status codes, non-blocking handlers, and well-documented rate limits - makes your API far less fragile when real users and AI agents start flipping your switches at scale.
Common Questions
Can I build production-ready REST and GraphQL APIs with Python in 2026?
Yes - using tools like FastAPI for REST and Strawberry for GraphQL you can build production-ready APIs; FastAPI benchmarks show it can handle 10,000+ requests per second and is used in production by companies like OpenAI. That said, AI scaffolding speeds setup but you still need dependency isolation, tests, auth/BOLA, rate limiting, and observability to make the service safe and maintainable.
What should I have installed and what skills do I need before I start?
Have Python 3.10+ and a per-project virtual environment, plus FastAPI, Strawberry, and testing libs (httpx/pytest) installed. Skill-wise be comfortable with basic Python, the command line, and core HTTP concepts (verbs, headers, status codes); plan to spend recurring weekly time (many learners put in ~10-20 hours/week) to close gaps like SQL and CI/CD.
Should I pick REST or GraphQL for a new API, or use both?
A hybrid approach is usually best: use REST for stable CRUD and clear service boundaries, and add GraphQL as a read-focused aggregation layer for complex UIs or AI agents. Industry surveys show REST remains dominant (~93% of teams for CRUD) while GraphQL adoption in enterprises has climbed into the ~50-60% range for data-hungry clients.
How do I secure my API so AI agents and real users can use it safely?
Use OAuth2-style bearer tokens (short-lived JWTs), enforce object-level authorization (BOLA) on every resource access, and add rate limiting that returns 429 with headers like X-RateLimit-Remaining and Retry-After. Treat these as core protections - API abuses have surged roughly 400% in recent analyses - not optional extras.
My endpoints sometimes return 200 with error details or unexpected 422s - what should I check first?
First, ensure you raise HTTPException with proper status codes and that your global exception handler preserves exc.status_code instead of returning 200. Then verify Pydantic models and Field constraints (use response_model to validate outputs) and log current_user_id vs stored owner_id to catch BOLA mismatches that cause unexpected 403/200 behaviors.
More How-To Guides:
Career-switchers should read this comprehensive guide to staying employable with AI in backend engineering.
Read our notes on how AI changes SQL workflows in 2026 and what skills still matter for engineers.
For deployment and safety, consult the guide to secure AI-assisted deployments and monitoring in production environments.
Use this beginner Docker tutorial with examples and volumes when you want a runnable project.
For a step-by-step plan, consult the guide to choosing the right backend lane and commit to 6-9 months of focused practice.
Irene Holden
Operations Manager
Former Microsoft Education and Learning Futures Group team member, Irene now oversees instructors at Nucamp while writing about everything tech - from careers to coding bootcamps.

