When I first started learning about what an API was and how I’d go about building one, I was a few years into my career. Everything I’ve learned pre-AI era was by trial-and-error and understanding how people use my API’s.
While API design isn’t complicated, it is deliberate and often overlooked. In the era of AI, we can have agents write the business logic with ease. However, leaving the design to an AI agent can cause inconsistent structure, unclear resource boundaries, and long-term maintainability.
Keep in mind that API design is all about tradeoffs - what’s “correct” in one system might be overkill in another. The principals listed here aren’t rules or “must-follows” for every API, they’re patterns you can follow.
Make it easy to consume
At one point, you’ve probably felt overwhelmed by how complex someone’s API is. Ultimately, an API should feel boring to use, as the complexity should stay hidden “behind the scenes”.
You should always be consistent with your output. For example, return JSON with a proper status code:
# Don't do this; it's not clear.
@app.post("/users")
def create_user(user: User):
return 42
# Do this instead.
from fastapi.responses import JSONResponse
@app.post("/users")
def create_user(user: User):
return JSONResponse(status_code=201, content={"id": 42})Think in Resources
A common mistake developers make is designing the API around actions (verbs):
# Get a user
@app.get("/get_user_by_id")
def get_user(user_id: int):
...
# Delete a user
@app.delete("/delete_user_by_id")
def delete_user(user_id: int):
...
# Model for creating a user
class UserCreate(BaseModel):
name: str
email: str
# Create a user
@app.post("/create_user")
def delete_user(user: UserCreate):
...Instead, let the HTTP method (GET, POST, PATCH, DELETE, etc) do the verb; use a noun to describe the endpoint:
# Gets a user
@app.get("/users/{user_id}")
def get_user(user_id: int):
...
# Deletes a user
@app.delete("/users/{user_id}")
def delete_user(user_id: int):
...
# Creates a user
@app.post("/users")
def create_user(user: UserCreate):
...I’d also recommend sticking with plural nouns for consistency purposes. That is, instead of /user/{user_id} use /users/{user_id}.
Design for Resilience
An API needs to be reliable, even under stress. You should be considering things such as retry logic, timeouts, rate limiting, and versioning (when necessary).
There’s a lot of ways to design an API to be resilient, but I’m highlighting 3 that are important:
Rate limiting
When building an application with FastAPI, you can use slowapi to add rate limiting to your application. This helps prevent abuse from users:
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(
RateLimitExceeded,
_rate_limit_exceeded_handler
)
@app.get("/health")
@limiter.limit("5/minute")
def health_check(request: Request):
return {"status": "ok"}Idempotency
Suppose that the user makes the same API call over and over again and it’s the same request, your system should be able to handle it so that it’s treated as the same effect as making it once - this is what idempotency is.
Designing an API that can handle network timeouts and server crashing before the response is received is critical, especially in distributed systems.
Without idempotency, you can risk things such as double-charging a user, sending multiple emails, or creating duplicate records in your database.
API versioning
Versioning your API is an extremely useful tool, but more often times than not, it’s overkill for the majority of applications.
Generally speaking, you’ll want to reserve versioning when there’s incompatible changes or architectural changes that fundamentally breaks with how users use your API. Examples of this can be switching from REST to GraphQL or restructuring entire response shapes.
If you’re simply renaming fields, fixing inconsistent behavior, or changing the defaults, don’t version your API. This increases the maintenance that’s needed and forces users to adopt the latest version. Instead, you’ll want to deprecate, add backward-compatible defaults/flags, and document the behavior changes.
Summary
At the end of the day, API design is about understanding trade-offs and how users would interpret your API. Business logic and endpoints will always be evolving, so building your API to adapt to these changes is key; every API will vary to the approach they take to solve this.
If you’re looking for an API design to follow, check out Beehiiv’s API (not sponsored, it’s designed well). It follows some of the principals explained here.
📧 Join the Python Snacks Newsletter! 🐍
Want even more Python-related content that’s useful? Here’s 3 reasons why you should subscribe the Python Snacks newsletter:
Get Ahead in Python with bite-sized Python tips and tricks delivered straight to your inbox, like the one above.
Exclusive Subscriber Perks: Receive a curated selection of up to 6 high-impact Python resources, tips, and exclusive insights with each email.
Get Smarter with Python in under 5 minutes. Your next Python breakthrough could just an email away.
You can unsubscribe at any time.
Interested in starting a newsletter or a blog?
Do you have a wealth of knowledge and insights to share with the world? Starting your own newsletter or blog is an excellent way to establish yourself as an authority in your field, connect with a like-minded community, and open up new opportunities.
If TikTok, Twitter, Facebook, or other social media platforms were to get banned, you’d lose all your followers. This is why you should start a newsletter: you own your audience.
This article may contain affiliate links. Affiliate links come at no cost to you and support the costs of this blog. Should you purchase a product/service from an affiliate link, it will come at no additional cost to you.

