Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 100 / 100

100 days of Python backend development: the recap explained

The lessons that matter most.

Expona Admin6 min read

100 days of Python backend development: the recap explained

We've spent the last 100 days building Python backends in production environments—wrestling with type systems, choosing between frameworks, optimizing queries, and learning that syntax matters far less than architecture. This recap distills the lessons that actually move the needle when you're responsible for systems that handle real traffic, real data, and real money.

Types make Python scale

Python's dynamic typing feels liberating until you're debugging a production issue at 3 AM because a function received a list instead of a dict. Type hints transform Python from a language that catches errors at runtime into one that catches them during development. We've learned this the hard way across trading platforms, e-commerce backends, and CRM systems where a single type mistake cascades through dozens of API calls.

Type hints alone aren't enough. Pydantic enforces types at runtime, catching malformed API payloads before they corrupt your database. Combined with mypy for static analysis, you get a safety net that scales from 10,000 to 100,000 lines of code without adding proportional testing burden.

Here's a real pattern we've used across multiple production systems:

python
from pydantic import BaseModel, Field, validator
from typing import Optional, List
import mypy

class OrderCreateRequest(BaseModel):
    customer_id: int = Field(..., gt=0)
    items: List[dict] = Field(..., min_items=1)
    discount_percent: Optional[float] = Field(default=0, ge=0, le=100)
    
    @validator('items')
    def validate_items(cls, v):
        for item in v:
            if 'product_id' not in item or 'quantity' not in item:
                raise ValueError('items must contain product_id and quantity')
        return v

async def create_order(order: OrderCreateRequest) -> OrderResponse:
    # Pydantic guarantees order.customer_id is a positive int
    # order.discount_percent is between 0-100
    # order.items is a non-empty list with validated structure
    pass

The investment in typing pays compound interest. A codebase that enforces types catches refactoring errors, prevents type coercion bugs, and lets junior engineers modify code with confidence. We've watched teams that skipped typing spend 40% of sprint time on preventable bugs. Teams that adopted strict typing moved at velocity.

Pick the right framework

Django versus FastAPI isn't a religious debate—it's a decision tree based on your problem. We've shipped both successfully, and the wrong choice costs months of work and technical debt.

Use Django when you need:

  • Admin panels and built-in ORM that handles 80% of queries
  • Authentication, permissions and middleware batteries included
  • A mature ecosystem with packages for literally everything
  • Teams with Python but not necessarily async/await expertise

Django's ORM feels like magic until you understand the query patterns it generates. We've debugged e-commerce checkouts where Django's N+1 queries turned a 50ms endpoint into a 2-second timeout under load. But once you know the patterns—prefetch_related, select_related, only(), defer()—Django scales predictably.

Use FastAPI when you need:

  • High-throughput APIs with concurrent I/O (WebSocket connections, async database drivers)
  • Type hints to drive both validation and API documentation simultaneously
  • Lightweight request/response cycles with minimal framework overhead
  • Native async/await support without retrofitting

FastAPI's dependency injection system is genuinely elegant and catches a class of bugs that other frameworks miss:

python
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession

app = FastAPI()

async def get_db() -> AsyncSession:
    async with AsyncSession() as session:
        yield session

async def get_current_user(db: AsyncSession = Depends(get_db)) -> User:
    # FastAPI injects dependencies, ensuring database connection is valid
    # Type hints make this contract explicit
    pass

@app.get("/orders/{order_id}")
async def fetch_order(
    order_id: int,
    db: AsyncSession = Depends(get_db),
    user: User = Depends(get_current_user)
) -> OrderResponse:
    pass

We've migrated ride-hailing services from Django REST Framework to FastAPI when throughput needed to scale from 1,000 to 50,000 requests per second. Same database, same logic, different framework, 5x improvement. The framework choice matters.

Databases decide performance

Your code is probably fine. Your database access patterns are probably not. We've walked into systems with reasonable Python implementations that collapsed under load because nobody measured what queries were actually running against PostgreSQL.

Three patterns destroy production databases:

1. N+1 queries

python
# BAD: Fetches user, then one query per order
orders = Order.query.filter(Order.user_id == 123).all()
for order in orders:
    print(order.user.name)  # Each iteration fires a query

# GOOD: Single query with joined data
orders = Order.query.options(
    joinedload(Order.user)
).filter(Order.user_id == 123).all()
for order in orders:
    print(order.user.name)  # No additional queries

2. Missing indexes

A table scan on a million-row table takes seconds. A btree index on the same query takes milliseconds. We've added single indexes that reduced endpoint latency from 4 seconds to 40ms. Indexes are free performance.

3. Connection pool exhaustion

A pool of 10 database connections servicing 100 concurrent requests creates a queue. Connections sit idle while requests wait. Proper pooling—via sqlalchemy.pool with appropriate size settings—eliminates this entirely. Production systems need min_size and max_overflow configured for their actual concurrency patterns.

Add monitoring that surfaces query time, connection pool stats, and slow queries. PostgreSQL's pg_stat_statements extension is invaluable. Tools like New Relic, DataDog or open-source alternatives (Prometheus + slow query logs) catch performance degradation before customers notice.

Production needs discipline

Syntax is learnable in a weekend. Production discipline takes years. The difference between code that works in a notebook and code that survives 3 AM outages is discipline: testing, observability, and safe deployment practices.

Testing isn't optional. We've seen single-line changes in "utility functions" take down payment processing. Comprehensive tests—unit tests for logic, integration tests for database interactions, contract tests for APIs—catch regressions before production. Aim for 70%+ coverage on business logic paths. Trust that stdlib and framework code works; test your code.

Observability wins incidents. When a SaaS backend serving 50,000 users stops responding at midnight, you need logs structured as JSON, distributed tracing across services, and metrics that show memory, CPU, database connection state, and request latency percentiles. A well-instrumented system tells you what broke in minutes. A poorly instrumented system forces you to guess for hours.

Use structured logging (python-json-logger or similar), emit metrics to Prometheus or CloudWatch, and trace requests through your system. When something breaks, these tools are your lifeline.

Safe deploys matter more than velocity. Blue-green deployments, canary releases, feature flags, and database migrations decoupled from code deploys are not nice-to-haves—they're requirements for production systems. We've watched teams deploy directly to production and survive weeks with 99% success rate, then have a single bad deploy corrupt the database, taking 8 hours to recover. The difference between 99% and 99.99% reliability is discipline in deployment process.

Common mistakes

  • Skipping type hints to ship faster. You ship 10% faster. You spend 40% more time debugging. The math doesn't work.
  • Not measuring database query time. Premature optimization is the root of all evil, but optimizing the wrong queries is worse. Measure first, optimize second.
  • Building without observability. Logs and metrics are not debugging tools for developers—they're production safety equipment. Add them from day one.
  • Deploying without tests. Confidence requires coverage. Aim for at least 60% on critical paths; uncovered deploys break in production, not in development.
  • Choosing frameworks based on tutorials, not production requirements. Flask is not appropriate for high-concurrency APIs. Django adds overhead FastAPI doesn't. Neither is right or wrong—they solve different problems.

Key takeaways

  • Type hints and Pydantic are not optional overhead—they're multipliers on team velocity and code reliability at scale.
  • Framework choice is determined by your throughput requirements and team expertise, not by which one is fashionable.
  • Database performance is determined by your access patterns (N+1 queries, missing indexes, connection pooling), not by your framework.
  • Production systems require discipline in testing, observability, and deployment—these matter more than elegant code.
  • Measure everything in production. Assumptions about performance are almost always wrong.
  • Safe deploys are cheaper than incident response. Invest in tooling.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles