Building a Scalable API with FastAPI: Lessons from Production
Building a Scalable API with FastAPI: Lessons from Production
When we decided to rebuild our API from Flask to FastAPI, I was skeptical. Another Python framework? Really? But after running FastAPI in production for 18 months serving 50 million requests per day, I'm a convert.
Why We Switched
Our Flask API was showing its age. Response times were creeping up. Adding new endpoints felt tedious. And the lack of automatic API documentation meant our frontend team was constantly asking about endpoint schemas.
FastAPI promised:
- Automatic OpenAPI documentation
- Built-in data validation with Pydantic
- Async support out of the box
- Type hints everywhere
We were sold on the pitch. But would it deliver in production?
The Migration
We didn't do a big bang rewrite. Instead, we ran Flask and FastAPI side-by-side, gradually migrating endpoints.
Here's a typical Flask endpoint:
@app.route('/users/<user_id>', methods=['GET'])
def get_user(user_id):
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {'error': 'Not found'}, 404
return jsonify(user.to_dict())
And the FastAPI version:
@app.get('/users/{user_id}', response_model=UserResponse)
async def get_user(user_id: int, db: Session = Depends(get_db)):
user = await db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail='User not found')
return user
More verbose? Slightly. But look at what we get for free:
- Type checking on user_id
- Automatic validation
- OpenAPI schema generation
- Response model validation
Performance Wins
The async support was game-changing. Our API makes a lot of external HTTP calls—payment processors, email services, analytics.
With Flask, these were blocking. With FastAPI's async/await, we could handle them concurrently.
Before: Average response time 450ms
After: Average response time 180ms
That's a 60% improvement without changing any business logic.
The Pydantic Magic
Pydantic models are FastAPI's secret weapon. They handle validation, serialization, and documentation automatically.
from pydantic import BaseModel, EmailStr, validator
class UserCreate(BaseModel):
email: EmailStr
password: str
age: int
@validator('password')
def password_strength(cls, v):
if len(v) < 8:
raise ValueError('Password too short')
return v
@validator('age')
def valid_age(cls, v):
if v < 18:
raise ValueError('Must be 18+')
return v
This model automatically:
- Validates email format
- Checks password length
- Validates age
- Generates OpenAPI schema
- Provides clear error messages
No more manual validation code scattered everywhere.
Dependency Injection
FastAPI's dependency injection system is elegant. Need database access? Inject it. Need the current user? Inject it.
async def get_current_user(token: str = Depends(oauth2_scheme)):
# Validate token, return user
return user
@app.get('/me')
async def read_users_me(current_user: User = Depends(get_current_user)):
return current_user
Dependencies can depend on other dependencies. It's composable and testable.
Testing Became Easier
FastAPI's TestClient makes testing trivial:
from fastapi.testclient import TestClient
client = TestClient(app)
def test_create_user():
response = client.post('/users', json={
'email': 'test@example.com',
'password': 'secure123',
'age': 25
})
assert response.status_code == 201
assert response.json()['email'] == 'test@example.com'
No need to spin up a server. Tests run fast and are easy to write.
The Challenges
Not everything was smooth:
Async Everywhere
Once you go async, everything needs to be async. Mixing sync and async code is tricky. We had to update database drivers, HTTP clients, and background tasks.
Learning Curve
The team needed time to understand async/await, Pydantic models, and dependency injection. It took about a month before everyone was comfortable.
Database Migrations
We use SQLAlchemy. The async version (SQLAlchemy 2.0) required some adjustments. Not FastAPI's fault, but part of the migration pain.
Production Lessons
1. Use Background Tasks
For non-critical operations, use FastAPI's background tasks:
from fastapi import BackgroundTasks
def send_email(email: str):
# Send email
pass
@app.post('/signup')
async def signup(user: UserCreate, background_tasks: BackgroundTasks):
# Create user
background_tasks.add_task(send_email, user.email)
return {'status': 'created'}
2. Implement Rate Limiting
FastAPI doesn't include rate limiting. We use slowapi:
from slowapi import Limiter
limiter = Limiter(key_func=get_remote_address)
@app.get('/api/data')
@limiter.limit('100/minute')
async def get_data():
return {'data': 'value'}
3. Monitor Everything
We use Prometheus metrics with fastapi-prometheus:
from fastapi_prometheus import PrometheusMiddleware
app.add_middleware(PrometheusMiddleware)
This gives us request counts, latencies, and error rates out of the box.
4. Handle Errors Gracefully
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
logger.error(f'Unhandled error: {exc}')
return JSONResponse(
status_code=500,
content={'detail': 'Internal server error'}
)
The Results
After 18 months in production:
- 99.97% uptime
- 50M requests/day
- Average response time: 180ms
- Zero scaling issues
- Team productivity up 40%
Would I Choose FastAPI Again?
Without hesitation. The automatic documentation alone saves hours every week. The performance is excellent. And the developer experience is the best I've had with any Python framework.
If you're building a new API or considering a migration, FastAPI should be on your shortlist. Just be ready to embrace async/await and invest time in learning Pydantic.
For us, it was absolutely worth it.