Author: kongastral

  • Building REST APIs with FastAPI: A Modern Python Web Framework Guide

    This post examines FastAPI in 2026 and demonstrates how to construct a production-ready REST API from scratch. In December 2018, a Colombian developer named Sebastián Ramírez pushed the first commit of a Python web framework to GitHub. Six years later, that project — FastAPI — has surpassed 80,000 stars, overtaken Flask in monthly downloads, and become the framework of choice at Netflix, Uber, Microsoft, and hundreds of startups building production APIs. The questions that arise are clear: what makes FastAPI so compelling that companies are rewriting entire API layers around it, and how can its capabilities be applied to build robust, production-ready REST APIs?

    For anyone familiar with the Python web ecosystem, the landscape has been dominated by two heavyweights for more than a decade: Flask, the minimalist micro-framework valued for its simplicity, and Django with its REST Framework, the batteries-included monolith favoured by enterprises. Both are excellent tools. They were designed, however, in a world before type hints became standard, before async was a first-class citizen in Python, and before API-first architectures became the default approach to building software.

    FastAPI was created in a different environment. It leverages modern Python features that make Python one of the most productive languages available today — type annotations, async/await, and Pydantic data validation — to deliver something that approaches a transformation in developer experience: ordinary annotated Python functions are written, and the framework automatically generates interactive API documentation, validates every request and response, and runs with performance that rivals Node.js and Go. In the independent TechEmpower Framework Benchmarks, FastAPI consistently ranks well above Flask in requests handled per second for I/O-bound workloads.

    This guide builds a complete REST API from zero to deployment. By the end, the reader will possess a fully functional task-management API with CRUD operations, database persistence, authentication, tests, and a production deployment strategy. Every code example is complete and runnable, permitting the reader to follow along step by step and conclude with a working API.

    The discussion follows.

    FastAPI Request-Response Lifecycle Client Browser / App HTTP FastAPI Routing + Validation Parsed Path Operation Your Python Function + Dependencies Result Response JSON + Status Code Response travels back to client

    Summary

    What this post covers: A zero-to-deployment FastAPI tutorial that builds a complete task-manager REST API with CRUD endpoints, Pydantic validation, SQLAlchemy persistence, JWT authentication, tests, and a production deployment strategy.

    Key insights:

    • FastAPI’s appeal is structural, not cosmetic—type hints + Pydantic + ASGI/Starlette give you automatic OpenAPI docs, request/response validation, and async I/O from the same function signature you would have written anyway.
    • In the independent TechEmpower Framework Benchmarks, FastAPI ranks well above Flask in requests per second for typical I/O-bound workloads, placing it in the same performance class as Node.js and Go.
    • Use Pydantic models as the single source of truth for request bodies, response shapes, and OpenAPI schema—if you find yourself duplicating field definitions between models and SQLAlchemy tables, you are doing it wrong.
    • Authentication is best implemented with FastAPI’s Depends() system: a single get_current_user dependency injected into protected routes keeps JWT decoding, expiry checks, and DB lookups out of your endpoint code.
    • For production, the right stack is Uvicorn (or Gunicorn with Uvicorn workers) behind Nginx, with structured logging, CORS configured explicitly per origin, and tests written against TestClient so they exercise the real ASGI app, not a mock.

    Main topics: Why FastAPI, Setting Up Your Environment, Your First API—Hello World, Building a Complete CRUD API—Task Manager, Request Validation and Pydantic Models, Path Parameters Query Parameters and Request Body, Adding a Database with SQLAlchemy, Authentication and Security, Middleware CORS and Error Handling, Testing Your API, Deployment, Best Practices.

    Why FastAPI?

    Before any code is written, the characteristics that distinguish FastAPI and explain its rapid adoption in the Python community warrant examination.

    Automatic OpenAPI and Swagger Documentation

    Every FastAPI application automatically generates an OpenAPI schema and serves an interactive Swagger UI at /docs and a ReDoc interface at /redoc. No plugins must be installed, no YAML files written, and no separate documentation maintained. The code is the documentation, and the two are always in sync.

    Type Hints and Pydantic Validation

    FastAPI is built on top of Pydantic, the most widely used data-validation library in Python. Request and response models are defined as simple Python classes with type annotations, and FastAPI automatically validates incoming data, serialises outgoing data, and generates accurate schema documentation — all from the same model definition.

    Native Async Support

    FastAPI natively supports Python’s async/await syntax. This permits the API to handle thousands of concurrent connections efficiently without blocking, which is critical for I/O-bound workloads such as database queries, external API calls, and file operations. Regular synchronous functions are also supported; FastAPI handles both seamlessly.

    Performance Comparable to Node.js and Go

    Owing to its ASGI foundation (powered by Starlette) and the Uvicorn server, FastAPI delivers exceptional performance. In the TechEmpower Web Framework Benchmarks, Python ASGI frameworks consistently outperform traditional WSGI frameworks by significant margins.

    Framework Comparison

    Feature FastAPI Flask Django REST Express.js
    Auto Documentation Built-in Plugin required Plugin required Plugin required
    Data Validation Built-in (Pydantic) Manual / Marshmallow Built-in (Serializers) Manual / Joi
    Async Support Native Limited Django 4.1+ Native
    Performance (req/s) ~15,000+ ~3,000 ~2,500 ~18,000+
    Learning Curve Easy Very Easy Moderate Easy
    Type Safety Full (type hints) None Partial TypeScript optional
    Dependency Injection Built-in No No No

     

    Key Takeaway: FastAPI provides the simplicity of Flask, the features of Django REST Framework, and performance that approaches Node.js — all in one package. For any new Python API project in 2026, FastAPI is the appropriate default choice.

    FastAPI Architecture Layers Routes (Path Operations) @app.get(“/tasks”) @app.post(“/tasks”) @app.put(“/tasks/{id}”) @app.delete(“/tasks/{id}”) Dependencies (Dependency Injection) Auth verification · DB session · Rate limiting · Request parsing Services (Business Logic) Validation rules · Data transformation · Error handling · Domain logic Database (SQLAlchemy / ORM)

    Setting Up Your Environment

    A clean development environment is the appropriate starting point. The discussion uses Python 3.11+ (though 3.9+ is also acceptable) and creates an isolated virtual environment for the project.

    Verify the Python Installation

    python3 --version
    # Python 3.11.x or higher recommended

    Create the Project Directory

    mkdir fastapi-task-manager
    cd fastapi-task-manager

    Set Up a Virtual Environment

    Two options are available. The classic venv approach is one:

    # Option 1: Classic venv
    python3 -m venv venv
    source venv/bin/activate  # On Windows: venv\Scripts\activate
    
    # Option 2: Using uv (much faster)
    pip install uv
    uv venv
    source .venv/bin/activate
    Tip: Anyone unfamiliar with uv should consider trying it. It is a Rust-based Python package manager that its developer Astral describes as 10–100x faster than pip, and it is rapidly becoming a standard tool for Python project management.

    Install FastAPI and Uvicorn

    # Install FastAPI with all optional dependencies
    pip install "fastapi[standard]"
    
    # This installs:
    # - fastapi (the framework)
    # - uvicorn (the ASGI server)
    # - pydantic (data validation)
    # - starlette (the underlying ASGI toolkit)
    # - httpx (for testing)
    # - python-multipart (for form data)
    # - jinja2 (for templates, if needed)

    Project Structure

    A clean project structure that will scale as the API grows is appropriate from the outset:

    fastapi-task-manager/
    ├── app/
    │   ├── __init__.py
    │   ├── main.py            # FastAPI app entry point
    │   ├── models.py           # Pydantic models (schemas)
    │   ├── database.py         # Database configuration
    │   ├── crud.py             # Database operations
    │   ├── auth.py             # Authentication logic
    │   └── routers/
    │       ├── __init__.py
    │       └── tasks.py        # Task endpoints
    ├── tests/
    │   ├── __init__.py
    │   └── test_tasks.py       # API tests
    ├── requirements.txt
    ├── Dockerfile
    └── .env

    Create the initial directory structure:

    mkdir -p app/routers tests
    touch app/__init__.py app/routers/__init__.py tests/__init__.py

    A First API: Hello World

    The most direct illustration begins with the simplest possible FastAPI application. The framework’s behaviour can then be observed.

    Create app/main.py:

    from fastapi import FastAPI
    
    app = FastAPI(
        title="Task Manager API",
        description="A complete REST API for managing tasks",
        version="1.0.0",
    )
    
    
    @app.get("/")
    def read_root():
        return {"message": "Welcome to the Task Manager API"}
    
    
    @app.get("/health")
    def health_check():
        return {"status": "healthy"}

    That is the entire requirement. Seven lines of actual code produce a working API with two endpoints. The application is run as follows:

    uvicorn app.main:app --reload --host 0.0.0.0 --port 8000

    The --reload flag enables hot reloading, so the server restarts automatically when code is changed. Output of the following form should appear:

    INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
    INFO:     Started reloader process [12345]
    INFO:     Started server process [12346]
    INFO:     Waiting for application startup.
    INFO:     Application startup complete.

    Exploring the Swagger UI

    Opening a browser at http://localhost:8000/docs reveals an attractive interactive API documentation page, generated entirely from the code. Any endpoint may be clicked, “Try it out” selected, and requests executed directly from the browser.

    The alternative documentation layout is available at http://localhost:8000/redoc, and the raw OpenAPI schema — importable into Postman, Insomnia, or any API client — is available at http://localhost:8000/openapi.json.

    Key Takeaway: No documentation code has been written, yet a fully interactive API explorer is available. This is one of FastAPI’s distinguishing features: code and documentation are always in sync because they are the same artefact.

    Building a Complete CRUD API: Task Manager

    The following section constructs a substantive example: a full task-management API with all CRUD operations, proper validation, error handling, and correct HTTP status codes. The discussion begins with in-memory storage to focus on API design, and a database is added later.

    REST API HTTP Methods Method Endpoint Action Status Code GET /tasks /tasks/{id} Read (list or single) 200 OK POST /tasks Create new resource 201 Created PUT /tasks/{id} Replace full resource 200 OK DELETE /tasks/{id} Remove resource 204 No Content

    Define Pydantic Models

    The first step is to define the data models. Create app/models.py:

    from pydantic import BaseModel, Field
    from typing import Optional
    from datetime import datetime
    from enum import Enum
    
    
    class TaskStatus(str, Enum):
        pending = "pending"
        in_progress = "in_progress"
        completed = "completed"
        cancelled = "cancelled"
    
    
    class TaskCreate(BaseModel):
        title: str = Field(
            ...,
            min_length=1,
            max_length=200,
            description="The title of the task",
            examples=["Buy groceries"],
        )
        description: Optional[str] = Field(
            None,
            max_length=2000,
            description="Detailed description of the task",
        )
        status: TaskStatus = Field(
            default=TaskStatus.pending,
            description="Current status of the task",
        )
        priority: int = Field(
            default=1,
            ge=1,
            le=5,
            description="Priority level from 1 (lowest) to 5 (highest)",
        )
    
    
    class TaskUpdate(BaseModel):
        title: Optional[str] = Field(
            None,
            min_length=1,
            max_length=200,
        )
        description: Optional[str] = Field(None, max_length=2000)
        status: Optional[TaskStatus] = None
        priority: Optional[int] = Field(None, ge=1, le=5)
    
    
    class TaskResponse(BaseModel):
        id: int
        title: str
        description: Optional[str] = None
        status: TaskStatus
        priority: int
        created_at: datetime
        updated_at: datetime

    The separation of concerns is important: TaskCreate represents what clients send when creating a task, TaskUpdate allows partial updates (all fields optional), and TaskResponse represents what the API returns. This is a critical design pattern; the internal data model should never be exposed directly.

    Build the CRUD Endpoints

    The actual API can now be built. Update app/main.py:

    from fastapi import FastAPI, HTTPException, Query
    from typing import Optional
    from datetime import datetime
    
    from app.models import TaskCreate, TaskUpdate, TaskResponse, TaskStatus
    
    app = FastAPI(
        title="Task Manager API",
        description="A complete REST API for managing tasks",
        version="1.0.0",
    )
    
    # In-memory storage
    tasks_db: dict[int, dict] = {}
    task_id_counter = 0
    
    
    def get_next_id() -> int:
        global task_id_counter
        task_id_counter += 1
        return task_id_counter
    
    
    @app.get("/")
    def read_root():
        return {"message": "Welcome to the Task Manager API"}
    
    
    @app.get("/tasks", response_model=list[TaskResponse])
    def list_tasks(
        status: Optional[TaskStatus] = Query(
            None, description="Filter tasks by status"
        ),
        priority: Optional[int] = Query(
            None, ge=1, le=5, description="Filter tasks by priority"
        ),
        skip: int = Query(0, ge=0, description="Number of tasks to skip"),
        limit: int = Query(
            20, ge=1, le=100, description="Maximum number of tasks to return"
        ),
    ):
        """Retrieve all tasks with optional filtering and pagination."""
        results = list(tasks_db.values())
    
        # Apply filters
        if status is not None:
            results = [t for t in results if t["status"] == status]
        if priority is not None:
            results = [t for t in results if t["priority"] == priority]
    
        # Apply pagination
        return results[skip : skip + limit]
    
    
    @app.get("/tasks/{task_id}", response_model=TaskResponse)
    def get_task(task_id: int):
        """Retrieve a single task by its ID."""
        if task_id not in tasks_db:
            raise HTTPException(
                status_code=404,
                detail=f"Task with ID {task_id} not found",
            )
        return tasks_db[task_id]
    
    
    @app.post("/tasks", response_model=TaskResponse, status_code=201)
    def create_task(task: TaskCreate):
        """Create a new task."""
        now = datetime.utcnow()
        task_id = get_next_id()
    
        task_data = {
            "id": task_id,
            "title": task.title,
            "description": task.description,
            "status": task.status,
            "priority": task.priority,
            "created_at": now,
            "updated_at": now,
        }
        tasks_db[task_id] = task_data
        return task_data
    
    
    @app.put("/tasks/{task_id}", response_model=TaskResponse)
    def update_task(task_id: int, task_update: TaskUpdate):
        """Update an existing task. Only provided fields will be updated."""
        if task_id not in tasks_db:
            raise HTTPException(
                status_code=404,
                detail=f"Task with ID {task_id} not found",
            )
    
        existing_task = tasks_db[task_id]
        update_data = task_update.model_dump(exclude_unset=True)
    
        for field, value in update_data.items():
            existing_task[field] = value
    
        existing_task["updated_at"] = datetime.utcnow()
        return existing_task
    
    
    @app.delete("/tasks/{task_id}", status_code=204)
    def delete_task(task_id: int):
        """Delete a task by its ID."""
        if task_id not in tasks_db:
            raise HTTPException(
                status_code=404,
                detail=f"Task with ID {task_id} not found",
            )
        del tasks_db[task_id]

    The key design decisions in this code merit explanation:

    Status code 201 for creation: The POST /tasks endpoint returns 201 (Created) instead of the default 200, which is the correct HTTP semantic for resource creation.

    Status code 204 for deletion: The DELETE endpoint returns 204 (No Content) with no response body, which is the standard for successful deletions.

    HTTPException for errors: When a task is not found, an HTTPException is raised with a 404 status code and a human-readable detail message. FastAPI converts this into a proper JSON error response automatically.

    Partial updates with exclude_unset: The model_dump(exclude_unset=True) call on the update model ensures that only fields explicitly sent by the client are updated. This is the correct behaviour for a PUT/PATCH endpoint.

    Testing the CRUD API

    The server is started with uvicorn app.main:app --reload, and the following requests may then be issued using curl:

    # Create a task
    curl -X POST http://localhost:8000/tasks \
      -H "Content-Type: application/json" \
      -d '{"title": "Learn FastAPI", "description": "Complete the tutorial", "priority": 5}'
    
    # List all tasks
    curl http://localhost:8000/tasks
    
    # Get a specific task
    curl http://localhost:8000/tasks/1
    
    # Update a task
    curl -X PUT http://localhost:8000/tasks/1 \
      -H "Content-Type: application/json" \
      -d '{"status": "in_progress"}'
    
    # Filter tasks by status
    curl "http://localhost:8000/tasks?status=in_progress"
    
    # Delete a task
    curl -X DELETE http://localhost:8000/tasks/1
    Tip: All of these endpoints can also be tested interactively through the Swagger UI at http://localhost:8000/docs. It is much faster for exploration than writing curl commands.

    Request Validation and Pydantic Models

    One of FastAPI’s most powerful features is its deep integration with Pydantic for data validation. The capabilities of Pydantic beyond the basics already discussed are examined below.

    Field Validation

    Pydantic’s Field function provides fine-grained control over validation:

    from pydantic import BaseModel, Field, field_validator
    import re
    
    
    class UserCreate(BaseModel):
        username: str = Field(
            ...,
            min_length=3,
            max_length=50,
            pattern=r"^[a-zA-Z0-9_]+$",
            description="Username (letters, numbers, underscores only)",
        )
        email: str = Field(
            ...,
            min_length=5,
            max_length=255,
            description="Valid email address",
        )
        age: int = Field(
            ...,
            gt=0,
            lt=150,
            description="Age in years",
        )
        score: float = Field(
            default=0.0,
            ge=0.0,
            le=100.0,
            description="Score between 0 and 100",
        )
    
        @field_validator("email")
        @classmethod
        def validate_email(cls, v: str) -> str:
            if "@" not in v or "." not in v.split("@")[-1]:
                raise ValueError("Invalid email address")
            return v.lower()

    The validation constraints available include:

    • min_length / max_length — for strings
    • pattern — regex validation for strings
    • gt / ge / lt / le — greater than, greater or equal, less than, less or equal, for numbers
    • multiple_of — ensures a number is a multiple of a given value

    Nested Models

    Pydantic models can be nested to represent complex data structures:

    from pydantic import BaseModel
    from typing import Optional
    
    
    class Address(BaseModel):
        street: str
        city: str
        state: str
        zip_code: str
        country: str = "US"
    
    
    class ContactInfo(BaseModel):
        email: str
        phone: Optional[str] = None
        address: Optional[Address] = None
    
    
    class Employee(BaseModel):
        name: str
        department: str
        contact: ContactInfo
        tags: list[str] = []
    
    
    # This would be valid JSON input:
    # {
    #     "name": "Alice",
    #     "department": "Engineering",
    #     "contact": {
    #         "email": "alice@example.com",
    #         "address": {
    #             "street": "123 Main St",
    #             "city": "San Francisco",
    #             "state": "CA",
    #             "zip_code": "94102"
    #         }
    #     },
    #     "tags": ["python", "fastapi"]
    # }

    Custom Validators

    For complex validation logic that goes beyond simple field constraints, Pydantic offers model validators that can validate relationships between fields:

    from pydantic import BaseModel, model_validator
    from datetime import date
    
    
    class DateRange(BaseModel):
        start_date: date
        end_date: date
    
        @model_validator(mode="after")
        def validate_date_range(self):
            if self.end_date < self.start_date:
                raise ValueError("end_date must be after start_date")
            return self
    
    
    class PasswordChange(BaseModel):
        current_password: str
        new_password: str = Field(min_length=8)
        confirm_password: str
    
        @model_validator(mode="after")
        def passwords_match(self):
            if self.new_password != self.confirm_password:
                raise ValueError("new_password and confirm_password must match")
            if self.new_password == self.current_password:
                raise ValueError("New password must differ from current password")
            return self

    When validation fails, FastAPI automatically returns a 422 (Unprocessable Entity) response with detailed error messages explaining exactly what went wrong and where. Clients receive clear, actionable error messages without any error-handling code having to be written.

    Path Parameters, Query Parameters, and Request Body

    FastAPI provides elegant means of extracting data from every part of an HTTP request. Each mechanism is examined below.

    Path Parameters

    Path parameters are extracted directly from the URL path and are always required:

    from fastapi import Path
    
    @app.get("/tasks/{task_id}/comments/{comment_id}")
    def get_comment(
        task_id: int = Path(..., gt=0, description="The task ID"),
        comment_id: int = Path(..., gt=0, description="The comment ID"),
    ):
        return {"task_id": task_id, "comment_id": comment_id}

    Query Parameters with Pagination

    Query parameters are well suited to filtering, sorting, and pagination:

    from fastapi import Query
    from typing import Optional
    from enum import Enum
    
    
    class SortField(str, Enum):
        created_at = "created_at"
        priority = "priority"
        title = "title"
    
    
    class SortOrder(str, Enum):
        asc = "asc"
        desc = "desc"
    
    
    @app.get("/tasks")
    def list_tasks(
        # Filtering
        status: Optional[TaskStatus] = Query(None),
        priority: Optional[int] = Query(None, ge=1, le=5),
        search: Optional[str] = Query(
            None, min_length=1, max_length=100,
            description="Search in title and description",
        ),
        # Sorting
        sort_by: SortField = Query(
            SortField.created_at, description="Field to sort by"
        ),
        order: SortOrder = Query(
            SortOrder.desc, description="Sort order"
        ),
        # Pagination
        skip: int = Query(0, ge=0, description="Records to skip"),
        limit: int = Query(20, ge=1, le=100, description="Max records"),
    ):
        """List tasks with filtering, sorting, and pagination."""
        results = list(tasks_db.values())
    
        if status:
            results = [t for t in results if t["status"] == status]
        if priority:
            results = [t for t in results if t["priority"] == priority]
        if search:
            results = [
                t for t in results
                if search.lower() in t["title"].lower()
                or (t["description"] and search.lower() in t["description"].lower())
            ]
    
        reverse = order == SortOrder.desc
        results.sort(key=lambda t: t[sort_by.value], reverse=reverse)
    
        return {
            "total": len(results),
            "skip": skip,
            "limit": limit,
            "tasks": results[skip : skip + limit],
        }

    Combining Path, Query, and Body in One Endpoint

    from fastapi import Path, Query, Body
    
    @app.put("/projects/{project_id}/tasks/{task_id}")
    def update_project_task(
        project_id: int = Path(..., gt=0),       # From URL path
        task_id: int = Path(..., gt=0),          # From URL path
        notify: bool = Query(False),              # From query string
        task_update: TaskUpdate = Body(...),      # From request body
    ):
        """
        URL: PUT /projects/5/tasks/42?notify=true
        Body: {"title": "Updated title", "priority": 3}
        """
        # project_id = 5 (from path)
        # task_id = 42 (from path)
        # notify = True (from query)
        # task_update = TaskUpdate(title="Updated title", priority=3) (from body)
        return {
            "project_id": project_id,
            "task_id": task_id,
            "notify": notify,
            "updates": task_update.model_dump(exclude_unset=True),
        }

    FastAPI automatically determines where each parameter originates based on its type: simple types are path or query parameters, while Pydantic models constitute the request body. The Path, Query, and Body functions allow validation and documentation to be attached to each.

    Adding a Database with SQLAlchemy

    In-memory storage is acceptable for prototyping, but any real application requires persistent data storage. The following section integrates SQLite with SQLAlchemy; the same pattern works with PostgreSQL, MySQL, or any other database.

    Install Database Dependencies

    pip install sqlalchemy

    Database Configuration

    Create app/database.py:

    from sqlalchemy import create_engine
    from sqlalchemy.orm import sessionmaker, DeclarativeBase
    
    SQLALCHEMY_DATABASE_URL = "sqlite:///./tasks.db"
    # For PostgreSQL:
    # SQLALCHEMY_DATABASE_URL = "postgresql://user:password@localhost/dbname"
    
    engine = create_engine(
        SQLALCHEMY_DATABASE_URL,
        connect_args={"check_same_thread": False},  # SQLite only
    )
    
    SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    
    
    class Base(DeclarativeBase):
        pass
    
    
    def get_db():
        """Dependency that provides a database session per request."""
        db = SessionLocal()
        try:
            yield db
        finally:
            db.close()

    Define Database Models

    Create app/db_models.py:

    from sqlalchemy import Column, Integer, String, DateTime, Enum as SQLEnum
    from sqlalchemy.sql import func
    
    from app.database import Base
    from app.models import TaskStatus
    
    
    class TaskDB(Base):
        __tablename__ = "tasks"
    
        id = Column(Integer, primary_key=True, index=True, autoincrement=True)
        title = Column(String(200), nullable=False)
        description = Column(String(2000), nullable=True)
        status = Column(
            SQLEnum(TaskStatus), default=TaskStatus.pending, nullable=False
        )
        priority = Column(Integer, default=1, nullable=False)
        created_at = Column(
            DateTime(timezone=True), server_default=func.now()
        )
        updated_at = Column(
            DateTime(timezone=True),
            server_default=func.now(),
            onupdate=func.now(),
        )

    CRUD Operations Module

    Create app/crud.py to separate database logic from endpoint logic:

    from sqlalchemy.orm import Session
    from typing import Optional
    
    from app.db_models import TaskDB
    from app.models import TaskCreate, TaskUpdate, TaskStatus
    
    
    def get_tasks(
        db: Session,
        status: Optional[TaskStatus] = None,
        priority: Optional[int] = None,
        skip: int = 0,
        limit: int = 20,
    ) -> list[TaskDB]:
        query = db.query(TaskDB)
    
        if status is not None:
            query = query.filter(TaskDB.status == status)
        if priority is not None:
            query = query.filter(TaskDB.priority == priority)
    
        return query.offset(skip).limit(limit).all()
    
    
    def get_task(db: Session, task_id: int) -> Optional[TaskDB]:
        return db.query(TaskDB).filter(TaskDB.id == task_id).first()
    
    
    def create_task(db: Session, task: TaskCreate) -> TaskDB:
        db_task = TaskDB(**task.model_dump())
        db.add(db_task)
        db.commit()
        db.refresh(db_task)
        return db_task
    
    
    def update_task(
        db: Session, task_id: int, task_update: TaskUpdate
    ) -> Optional[TaskDB]:
        db_task = db.query(TaskDB).filter(TaskDB.id == task_id).first()
        if db_task is None:
            return None
    
        update_data = task_update.model_dump(exclude_unset=True)
        for field, value in update_data.items():
            setattr(db_task, field, value)
    
        db.commit()
        db.refresh(db_task)
        return db_task
    
    
    def delete_task(db: Session, task_id: int) -> bool:
        db_task = db.query(TaskDB).filter(TaskDB.id == task_id).first()
        if db_task is None:
            return False
        db.delete(db_task)
        db.commit()
        return True

    Refactored Endpoints with Database

    The endpoints in app/main.py are now updated to use the database:

    from fastapi import FastAPI, HTTPException, Query, Depends
    from sqlalchemy.orm import Session
    from typing import Optional
    
    from app.models import (
        TaskCreate, TaskUpdate, TaskResponse, TaskStatus,
    )
    from app.database import engine, get_db
    from app.db_models import Base
    from app import crud
    
    # Create database tables on startup
    Base.metadata.create_all(bind=engine)
    
    app = FastAPI(
        title="Task Manager API",
        description="A complete REST API for managing tasks",
        version="1.0.0",
    )
    
    
    @app.get("/")
    def read_root():
        return {"message": "Welcome to the Task Manager API"}
    
    
    @app.get("/tasks", response_model=list[TaskResponse])
    def list_tasks(
        status: Optional[TaskStatus] = Query(None),
        priority: Optional[int] = Query(None, ge=1, le=5),
        skip: int = Query(0, ge=0),
        limit: int = Query(20, ge=1, le=100),
        db: Session = Depends(get_db),
    ):
        """Retrieve all tasks with optional filtering and pagination."""
        return crud.get_tasks(db, status=status, priority=priority,
                              skip=skip, limit=limit)
    
    
    @app.get("/tasks/{task_id}", response_model=TaskResponse)
    def get_task(task_id: int, db: Session = Depends(get_db)):
        """Retrieve a single task by its ID."""
        task = crud.get_task(db, task_id)
        if task is None:
            raise HTTPException(status_code=404,
                                detail=f"Task {task_id} not found")
        return task
    
    
    @app.post("/tasks", response_model=TaskResponse, status_code=201)
    def create_task(task: TaskCreate, db: Session = Depends(get_db)):
        """Create a new task."""
        return crud.create_task(db, task)
    
    
    @app.put("/tasks/{task_id}", response_model=TaskResponse)
    def update_task(
        task_id: int,
        task_update: TaskUpdate,
        db: Session = Depends(get_db),
    ):
        """Update an existing task."""
        task = crud.update_task(db, task_id, task_update)
        if task is None:
            raise HTTPException(status_code=404,
                                detail=f"Task {task_id} not found")
        return task
    
    
    @app.delete("/tasks/{task_id}", status_code=204)
    def delete_task(task_id: int, db: Session = Depends(get_db)):
        """Delete a task by its ID."""
        if not crud.delete_task(db, task_id):
            raise HTTPException(status_code=404,
                                detail=f"Task {task_id} not found")

    The key change is the Depends(get_db) pattern. This is FastAPI’s dependency injection system: it automatically creates a database session for each request and closes it when the request is complete, even if an error occurs. The pattern is clean, testable, and avoids global state.

    Tip: For new projects, SQLModel may be preferable to separate SQLAlchemy and Pydantic models. Created by the same author as FastAPI, SQLModel permits a single class to serve as both Pydantic model and SQLAlchemy model, significantly reducing duplication.

    Authentication and Security

    No production API is complete without authentication. Two approaches are implemented below: a simple API key for server-to-server communication, and JWT tokens for user-facing authentication.

    Simple API Key Authentication

    Create app/auth.py:

    from fastapi import Depends, HTTPException, Security, status
    from fastapi.security import APIKeyHeader, OAuth2PasswordBearer, OAuth2PasswordRequestForm
    from jose import JWTError, jwt
    from passlib.context import CryptContext
    from datetime import datetime, timedelta
    from typing import Optional
    from pydantic import BaseModel
    
    # ── API Key Authentication ──────────────────────────
    
    API_KEY = "your-secret-api-key-here"  # In production, load from env
    api_key_header = APIKeyHeader(name="X-API-Key")
    
    
    def verify_api_key(api_key: str = Security(api_key_header)):
        if api_key != API_KEY:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Invalid API key",
            )
        return api_key
    
    
    # ── JWT Authentication ──────────────────────────────
    
    SECRET_KEY = "your-jwt-secret-key"  # In production, load from env
    ALGORITHM = "HS256"
    ACCESS_TOKEN_EXPIRE_MINUTES = 30
    
    pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
    oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
    
    
    class Token(BaseModel):
        access_token: str
        token_type: str
    
    
    class TokenData(BaseModel):
        username: Optional[str] = None
    
    
    class User(BaseModel):
        username: str
        email: str
        disabled: bool = False
    
    
    class UserInDB(User):
        hashed_password: str
    
    
    # Simulated user database
    fake_users_db = {
        "admin": {
            "username": "admin",
            "email": "admin@example.com",
            "hashed_password": pwd_context.hash("secretpassword"),
            "disabled": False,
        }
    }
    
    
    def verify_password(plain_password: str, hashed_password: str) -> bool:
        return pwd_context.verify(plain_password, hashed_password)
    
    
    def create_access_token(
        data: dict, expires_delta: Optional[timedelta] = None
    ) -> str:
        to_encode = data.copy()
        expire = datetime.utcnow() + (
            expires_delta or timedelta(minutes=15)
        )
        to_encode.update({"exp": expire})
        return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    
    
    def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
        credentials_exception = HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Could not validate credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )
        try:
            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
            username: str = payload.get("sub")
            if username is None:
                raise credentials_exception
        except JWTError:
            raise credentials_exception
    
        user_data = fake_users_db.get(username)
        if user_data is None:
            raise credentials_exception
    
        return User(**user_data)

    Protecting Endpoints

    Any endpoint can now be protected by adding the dependency:

    from app.auth import (
        verify_api_key, get_current_user, User, Token,
        create_access_token, verify_password, fake_users_db,
        ACCESS_TOKEN_EXPIRE_MINUTES,
    )
    from fastapi.security import OAuth2PasswordRequestForm
    
    
    # Token endpoint for JWT login
    @app.post("/token", response_model=Token)
    def login(form_data: OAuth2PasswordRequestForm = Depends()):
        user_data = fake_users_db.get(form_data.username)
        if not user_data or not verify_password(
            form_data.password, user_data["hashed_password"]
        ):
            raise HTTPException(
                status_code=401,
                detail="Incorrect username or password",
                headers={"WWW-Authenticate": "Bearer"},
            )
    
        access_token = create_access_token(
            data={"sub": form_data.username},
            expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
        )
        return {"access_token": access_token, "token_type": "bearer"}
    
    
    # Protected endpoint — requires JWT token
    @app.get("/users/me", response_model=User)
    def read_users_me(current_user: User = Depends(get_current_user)):
        return current_user
    
    
    # Protected endpoint — requires API key
    @app.delete("/admin/clear-tasks", dependencies=[Depends(verify_api_key)])
    def clear_all_tasks(db: Session = Depends(get_db)):
        db.query(TaskDB).delete()
        db.commit()
        return {"message": "All tasks deleted"}

    Install the required packages for JWT authentication:

    pip install python-jose[cryptography] passlib[bcrypt]
    Caution: Secret keys and passwords must never be hard-coded in source code. In a production application, SECRET_KEY, API_KEY, and database credentials should always be loaded from environment variables using python-dotenv or pydantic-settings. The hard-coded values here are for tutorial purposes only. For a broader treatment of containerising the API securely, see the related Docker containers explained guide.

    Middleware, CORS, and Error Handling

    As the API grows, cross-cutting concerns such as CORS support (so that frontends can call the API), request logging, and global error handling become necessary.

    Adding CORS for Frontend Access

    from fastapi.middleware.cors import CORSMiddleware
    
    app.add_middleware(
        CORSMiddleware,
        allow_origins=[
            "http://localhost:3000",      # React dev server
            "https://yourdomain.com",      # Production frontend
        ],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    Custom Middleware for Logging and Timing

    import time
    import logging
    from fastapi import Request
    
    logger = logging.getLogger("api")
    
    
    @app.middleware("http")
    async def log_requests(request: Request, call_next):
        start_time = time.time()
    
        # Process the request
        response = await call_next(request)
    
        # Calculate duration
        duration = time.time() - start_time
    
        logger.info(
            f"{request.method} {request.url.path} "
            f"- Status: {response.status_code} "
            f"- Duration: {duration:.3f}s"
        )
    
        # Add timing header to response
        response.headers["X-Process-Time"] = f"{duration:.3f}"
        return response

    Global Exception Handlers

    from fastapi import Request
    from fastapi.responses import JSONResponse
    
    
    @app.exception_handler(ValueError)
    async def value_error_handler(request: Request, exc: ValueError):
        return JSONResponse(
            status_code=400,
            content={
                "error": "Bad Request",
                "detail": str(exc),
            },
        )
    
    
    @app.exception_handler(Exception)
    async def general_exception_handler(request: Request, exc: Exception):
        logger.error(f"Unhandled exception: {exc}", exc_info=True)
        return JSONResponse(
            status_code=500,
            content={
                "error": "Internal Server Error",
                "detail": "An unexpected error occurred",
            },
        )

    The general exception handler is particularly important for production: it prevents stack traces from leaking to clients while still logging the full error for debugging.

    Testing the API

    FastAPI makes testing exceptionally straightforward with its built-in TestClient, which is a wrapper around httpx. The entire API can be tested without starting a server.

    Setting Up Tests

    Install pytest if it is not already present:

    pip install pytest httpx

    Create tests/test_tasks.py:

    import pytest
    from fastapi.testclient import TestClient
    from sqlalchemy import create_engine
    from sqlalchemy.orm import sessionmaker
    
    from app.main import app
    from app.database import Base, get_db
    
    # Use an in-memory SQLite database for tests
    TEST_DATABASE_URL = "sqlite:///./test.db"
    engine = create_engine(
        TEST_DATABASE_URL,
        connect_args={"check_same_thread": False},
    )
    TestingSessionLocal = sessionmaker(
        autocommit=False, autoflush=False, bind=engine
    )
    
    
    def override_get_db():
        db = TestingSessionLocal()
        try:
            yield db
        finally:
            db.close()
    
    
    # Override the database dependency
    app.dependency_overrides[get_db] = override_get_db
    client = TestClient(app)
    
    
    @pytest.fixture(autouse=True)
    def setup_database():
        """Create tables before each test, drop after."""
        Base.metadata.create_all(bind=engine)
        yield
        Base.metadata.drop_all(bind=engine)
    
    
    def test_read_root():
        response = client.get("/")
        assert response.status_code == 200
        assert response.json() == {"message": "Welcome to the Task Manager API"}
    
    
    def test_create_task():
        response = client.post(
            "/tasks",
            json={
                "title": "Test Task",
                "description": "A test task",
                "priority": 3,
            },
        )
        assert response.status_code == 201
        data = response.json()
        assert data["title"] == "Test Task"
        assert data["description"] == "A test task"
        assert data["priority"] == 3
        assert data["status"] == "pending"
        assert "id" in data
        assert "created_at" in data
    
    
    def test_create_task_validation_error():
        response = client.post(
            "/tasks",
            json={"title": "", "priority": 10},  # Empty title, priority too high
        )
        assert response.status_code == 422
    
    
    def test_get_task():
        # Create a task first
        create_response = client.post(
            "/tasks", json={"title": "Find me"}
        )
        task_id = create_response.json()["id"]
    
        # Retrieve it
        response = client.get(f"/tasks/{task_id}")
        assert response.status_code == 200
        assert response.json()["title"] == "Find me"
    
    
    def test_get_task_not_found():
        response = client.get("/tasks/99999")
        assert response.status_code == 404
    
    
    def test_update_task():
        # Create a task
        create_response = client.post(
            "/tasks", json={"title": "Original Title"}
        )
        task_id = create_response.json()["id"]
    
        # Update it
        response = client.put(
            f"/tasks/{task_id}",
            json={"title": "Updated Title", "status": "in_progress"},
        )
        assert response.status_code == 200
        assert response.json()["title"] == "Updated Title"
        assert response.json()["status"] == "in_progress"
    
    
    def test_delete_task():
        # Create a task
        create_response = client.post(
            "/tasks", json={"title": "Delete me"}
        )
        task_id = create_response.json()["id"]
    
        # Delete it
        response = client.delete(f"/tasks/{task_id}")
        assert response.status_code == 204
    
        # Verify it is gone
        response = client.get(f"/tasks/{task_id}")
        assert response.status_code == 404
    
    
    def test_list_tasks_with_filter():
        # Create tasks with different statuses
        client.post(
            "/tasks", json={"title": "Task 1", "status": "pending"}
        )
        client.post(
            "/tasks", json={"title": "Task 2", "status": "completed"}
        )
        client.post(
            "/tasks", json={"title": "Task 3", "status": "pending"}
        )
    
        # Filter by status
        response = client.get("/tasks?status=pending")
        assert response.status_code == 200
        tasks = response.json()
        assert len(tasks) == 2
        assert all(t["status"] == "pending" for t in tasks)
    
    
    def test_list_tasks_pagination():
        # Create 5 tasks
        for i in range(5):
            client.post("/tasks", json={"title": f"Task {i}"})
    
        # Get first page
        response = client.get("/tasks?skip=0&limit=2")
        assert response.status_code == 200
        assert len(response.json()) == 2
    
        # Get second page
        response = client.get("/tasks?skip=2&limit=2")
        assert response.status_code == 200
        assert len(response.json()) == 2

    Run the tests:

    pytest tests/ -v
    Key Takeaway: The dependency-injection system renders testing clean: the real database is replaced by a test database with a single line (app.dependency_overrides[get_db] = override_get_db). No mocking, no patching, no test doubles. This is one of FastAPI’s most underappreciated features.

    Deployment

    The following section describes taking the API from development to production.

    Running in Production with Gunicorn

    In production, Uvicorn should be run behind Gunicorn for process management and multi-worker support:

    pip install gunicorn
    
    # Run with 4 worker processes
    gunicorn app.main:app \
        --workers 4 \
        --worker-class uvicorn.workers.UvicornWorker \
        --bind 0.0.0.0:8000 \
        --access-logfile - \
        --error-logfile -

    A useful rule of thumb for the number of workers is (2 x CPU cores) + 1. For a 2-core server, five workers are appropriate.

    Docker Containerisation

    A Dockerfile is used to containerise the FastAPI application. For a thorough treatment of Docker from development to production, including multi-stage builds and Docker Compose, see the related Docker containers guide for development and production:

    # Use the official Python slim image
    FROM python:3.11-slim
    
    # Set working directory
    WORKDIR /app
    
    # Install dependencies first (leverages Docker caching)
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    
    # Copy application code
    COPY app/ ./app/
    
    # Create non-root user for security
    RUN adduser --disabled-password --gecos "" appuser
    USER appuser
    
    # Expose port
    EXPOSE 8000
    
    # Run with Gunicorn in production
    CMD ["gunicorn", "app.main:app", \
         "--workers", "4", \
         "--worker-class", "uvicorn.workers.UvicornWorker", \
         "--bind", "0.0.0.0:8000"]

    And a docker-compose.yml for easy local testing:

    version: "3.8"
    services:
      api:
        build: .
        ports:
          - "8000:8000"
        environment:
          - DATABASE_URL=postgresql://postgres:password@db:5432/taskmanager
          - SECRET_KEY=your-production-secret-key
        depends_on:
          - db
    
      db:
        image: postgres:16
        environment:
          - POSTGRES_DB=taskmanager
          - POSTGRES_PASSWORD=password
        volumes:
          - postgres_data:/var/lib/postgresql/data
        ports:
          - "5432:5432"
    
    volumes:
      postgres_data:

    Build and run:

    docker-compose up --build

    Cloud Deployment Options

    Several cloud-deployment options are available, depending on scale and budget:

    • AWS Lightsail or EC2 — full control, appropriate for small to medium deployments
    • Google Cloud Run — serverless containers, scaling to zero, pay-per-request pricing
    • Railway or Render — simple PaaS options with generous free tiers
    • AWS Lambda with Mangum — serverless deployment using the Mangum ASGI adapter

    Best Practices

    As an API grows beyond a simple tutorial, the following practices keep the codebase maintainable and the API reliable.

    Project Structure for Larger Applications

    For larger applications, the code should be organised using FastAPI’s router system:

    app/
    ├── __init__.py
    ├── main.py                 # App factory, middleware, startup events
    ├── config.py               # Settings via pydantic-settings
    ├── database.py             # DB engine, session, base
    ├── dependencies.py         # Shared dependencies (auth, db session)
    ├── models/                 # SQLAlchemy models
    │   ├── __init__.py
    │   ├── task.py
    │   └── user.py
    ├── schemas/                # Pydantic schemas
    │   ├── __init__.py
    │   ├── task.py
    │   └── user.py
    ├── routers/                # API route handlers
    │   ├── __init__.py
    │   ├── tasks.py
    │   └── users.py
    ├── services/               # Business logic
    │   ├── __init__.py
    │   ├── task_service.py
    │   └── user_service.py
    └── middleware/              # Custom middleware
        ├── __init__.py
        └── logging.py

    Each router file has the following structure:

    # app/routers/tasks.py
    from fastapi import APIRouter, Depends
    from sqlalchemy.orm import Session
    
    from app.dependencies import get_db, get_current_user
    from app.schemas.task import TaskCreate, TaskResponse
    from app.services import task_service
    
    router = APIRouter(
        prefix="/tasks",
        tags=["tasks"],
        dependencies=[Depends(get_current_user)],
    )
    
    
    @router.get("/", response_model=list[TaskResponse])
    def list_tasks(db: Session = Depends(get_db)):
        return task_service.get_all_tasks(db)

    The main file then includes the routers:

    # app/main.py
    from fastapi import FastAPI
    from app.routers import tasks, users
    
    app = FastAPI(title="Task Manager API")
    app.include_router(tasks.router)
    app.include_router(users.router)

    Environment Variables with Pydantic Settings

    # app/config.py
    from pydantic_settings import BaseSettings
    from functools import lru_cache
    
    
    class Settings(BaseSettings):
        database_url: str = "sqlite:///./tasks.db"
        secret_key: str = "change-me-in-production"
        api_key: str = "change-me-in-production"
        debug: bool = False
        allowed_origins: list[str] = ["http://localhost:3000"]
    
        class Config:
            env_file = ".env"
    
    
    @lru_cache
    def get_settings() -> Settings:
        return Settings()
    
    
    # Usage in endpoints:
    # settings = Depends(get_settings)

    API Versioning

    # Version via URL prefix
    v1_router = APIRouter(prefix="/api/v1")
    v2_router = APIRouter(prefix="/api/v2")
    
    app.include_router(v1_router)
    app.include_router(v2_router)

    Rate Limiting

    For rate limiting, the slowapi library integrates cleanly with FastAPI:

    pip install slowapi
    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.state.limiter = limiter
    app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
    
    
    @app.get("/tasks")
    @limiter.limit("60/minute")
    def list_tasks(request: Request):
        ...
    Key Takeaway: FastAPI’s modular architecture — routers, dependency injection, Pydantic settings — makes it straightforward to scale from a single-file prototype to a well-structured production application. The appropriate approach is to begin simply and refactor as the project grows.

    Concluding Observations

    This guide has covered substantial ground. Beginning from a simple “Hello World” endpoint, a complete task-management API has been constructed with CRUD operations, database persistence using SQLAlchemy, authentication with both API keys and JWT tokens, CORS support, custom middleware, comprehensive tests, and a production deployment configured with Docker.

    What distinguishes FastAPI is not any single feature; it is how all of its features work together seamlessly. Type hints drive validation, documentation, and editor support simultaneously. Dependency injection keeps code testable and modular. Pydantic models serve as the single source of truth for data contracts. The async foundation permits the API to handle serious traffic without complex optimisation.

    The components constructed in this guide are summarised below:

    Component Technology Purpose
    Framework FastAPI API routing, validation, docs
    Server Uvicorn / Gunicorn ASGI server for production
    Validation Pydantic Request/response data models
    Database SQLAlchemy + SQLite Persistent data storage
    Authentication JWT + API Keys Secure endpoint access
    Testing pytest + TestClient Automated API testing
    Deployment Docker + Gunicorn Containerized production setup

     

    For teams seeking still more performance from the API layer, writing performance-critical endpoints as native extensions is becoming practical owing to Python and Rust interoperability via PyO3. For developers migrating from Flask, the transition to FastAPI is remarkably smooth: most concepts map directly, and type safety, auto-generated documentation, and improved performance are gained without additional effort. For developers migrating from Django REST Framework, the lighter weight and more explicit architecture, with comparable functionality, are likely to be appreciated.

    The Python web ecosystem has evolved significantly, and FastAPI represents the present state of the art. Whether the project is a simple microservice, a complex multi-tenant SaaS, or a high-performance data API, FastAPI provides the tools to build it cleanly and efficiently.

    As the codebase grows, following clean-code principles and using Git best practices for professional developers will keep the API maintainable. Building something real is the appropriate next step. The task manager constructed here can be extended with additional features — tags, due dates, user assignments, notifications — and deployed. The most effective way to learn a framework is to ship something with it.

    References

  • Time-Series Forecasting in 2026: From ARIMA to Foundation Models — A Complete Guide

    The short version

    Three families of methods now compete for every forecasting problem, and the right choice depends far more on the problem than on the calendar. Classical statistical models (ARIMA, ETS, the seasonal naive baseline) remain hard to beat on well-behaved univariate series and should always be benchmarked first. Gradient-boosted trees such as LightGBM quietly win most feature-rich, tabular forecasting tasks, including the M5 competition, where they beat every deep learning entry. Among neural methods, PatchTST and iTransformer show that the right inductive bias—patching the time axis, or inverting which dimension attention runs over—matters more than raw model size, while foundation models like TimesFM, Chronos, and Moirai now deliver competitive zero-shot forecasts and are closing the gap on supervised accuracy through light fine-tuning. The recurring lesson across all of them is that the best forecasting system is a disciplined pipeline—clean data, honest time-series cross-validation, reconciliation, and monitoring—not a single favoured architecture.

    Forecasting a time series—estimating future values from an ordered history of past observations—is one of the oldest problems in applied statistics, and one of the few whose methodological center of gravity has moved twice in five years. Until roughly 2020, production forecasting meant ARIMA, exponential smoothing, and a set of tuned heuristics. By 2023 a family of transformer architectures had taken the accuracy lead on standard multivariate benchmarks. By 2026 the frontier has shifted again, toward pre-trained foundation models that forecast series they were never trained on. Each shift left the previous generation of methods in place rather than retiring it, which is the main reason model selection has become harder rather than easier.

    The stakes are high because the task is nearly universal. Retailers forecast demand to stock shelves. Energy companies forecast load to schedule generation. Financial institutions forecast volatility to price options. Hospitals forecast patient admissions to staff wards. The accuracy of these forecasts directly determines whether resources are allocated efficiently or wasted at scale, and the same three-way choice—classical, deep learning, or foundation model—now confronts every team building such a system.

    The most consequential change is recent. Deep learning architectures—N-BEATS, Temporal Fusion Transformers, DeepAR—first challenged classical methods on complex, multivariate problems in the early 2020s. In 2025 and 2026, the defining shift has been the arrival of foundation models pre-trained on billions of time points that can forecast previously unseen series without any task-specific training. The practical implications are large, and the uncertainty about which model to reach for has rarely been greater.

    The sections that follow trace this evolution from classical methods through deep learning to the current frontier, benchmark the models that matter on standard datasets, and set out a framework for choosing an approach for a given problem—with attention throughout to what works, what does not, and why.

    Time-Series Forecasting: Model Evolution ARIMA / ETS 1970s–2010s Statistical LSTM DeepAR 2018–2021 Deep Learning Patch TST / iTrans 2022–2023 Deep Learning Transformer Era Foundation Models 2024–Present Zero-shot / Pre-trained

    Why Time-Series Forecasting Matters More Than Ever

    The volume of time-stamped data generated globally has expanded sharply. IoT sensors, financial markets, application telemetry, social media engagement metrics, weather stations, and wearable health devices all produce continuous streams of sequential observations. Organisations that aim to derive value from this data require not only appropriate forecasting models but also suitable databases for storing preprocessed time-series data and robust pipelines for moving data between systems. IDC’s Data Age 2025 study projected the global datasphere at roughly 175 zettabytes in 2025, a large and growing share of it timestamped and sequential rather than static.

    Volume alone, however, does not explain why forecasting has become more important. Three structural trends are increasing demand for accurate predictions:

    Just-in-time operations. Modern supply chains, cloud infrastructure, and service delivery systems operate with minimal slack. Real-time complex event processing pipelines built on Apache Flink are increasingly paired with forecasting models to detect anomalies as they occur. Amazon’s fulfilment network, Uber’s driver allocation, and Netflix’s content delivery all depend on accurate short-term forecasts to match supply with demand in near real time. Forecast errors of even 10% result in either costly over-provisioning or customer-visible failures.

    Renewable energy integration. As solar and wind generation transitions from supplementary to primary energy sources, grid operators must forecast intermittent generation with high accuracy to maintain stability. A 5% error in the solar generation forecast for a large grid can mean the difference between smooth operation and emergency natural gas peaking, with associated costs measured in millions of dollars and unnecessary emissions.

    Algorithmic decision-making at scale. Automated systems, ranging from algorithmic trading to dynamic pricing and autonomous vehicle planning, consume forecasts as inputs to decisions that execute without human review. The performance ceiling of these systems is bounded by the accuracy of their underlying forecasts.

    Key Takeaway: Time-series forecasting has evolved from a quarterly planning exercise carried out by analysts into an operational capability that runs continuously, feeds automated systems, and directly affects revenue and reliability. The standard for accuracy, and the cost of inaccuracy, has rarely been higher.

    Classical Foundations That Still Work

    Before turning to transformers and foundation models, it is important to acknowledge that classical statistical methods remain highly competitive on many forecasting problems. The 2022 M5 competition and subsequent analyses have repeatedly shown that simple methods, properly tuned, often match or surpass complex deep learning models on univariate and low-dimensional problems.

    ARIMA and SARIMA

    AutoRegressive Integrated Moving Average (ARIMA) models capture three components of a time series: autoregressive behaviour (current values depend on past values), differencing (to achieve stationarity), and moving average effects (current values depend on past forecast errors). The seasonal variant, SARIMA, adds explicit seasonal terms.

    ARIMA’s principal strengths are its theoretical foundation and interpretability: every parameter carries a clear statistical meaning. Its weakness is that it assumes linear relationships and handles only univariate series. For a single well-behaved time series with clear trend and seasonality (monthly sales, daily temperature), ARIMA remains a strong, fast, and interpretable baseline. When working with sensor data at scale, pairing ARIMA with a sound metadata management strategy for facility and sensor signals ensures that the appropriate model can be tracked against each data stream.

    Exponential Smoothing (ETS)

    Exponential Smoothing State Space models (ETS) decompose a time series into error, trend, and seasonal components, each of which can be additive or multiplicative. The Holt-Winters method, a specific ETS configuration with additive or multiplicative trend and seasonality, is among the most widely deployed forecasting models in industry, particularly in retail demand planning.

    Prophet

    Prophet (Taylor and Letham, 2018, Meta) was designed for business forecasting at scale. It decomposes time series into trend, seasonality (multiple periods), and holiday effects, fitted using a Bayesian approach. Prophet’s principal innovation was practical: it handles missing data gracefully, automatically detects changepoints in trend, and allows users to inject domain knowledge (holidays, known events) without statistical expertise. While no longer the most accurate option, Prophet remains one of the fastest paths from raw data to a reasonable forecast for business metrics.

    from prophet import Prophet
    import pandas as pd
    
    # Prophet requires a DataFrame with 'ds' (date) and 'y' (value) columns
    df = pd.DataFrame({'ds': dates, 'y': values})
    
    model = Prophet(
        yearly_seasonality=True,
        weekly_seasonality=True,
        daily_seasonality=False,
        changepoint_prior_scale=0.05,  # Controls trend flexibility
    )
    model.add_country_holidays(country_name='US')
    model.fit(df)
    
    # Forecast 90 days ahead
    future = model.make_future_dataframe(periods=90)
    forecast = model.predict(future)
    
    # forecast contains: yhat, yhat_lower, yhat_upper (prediction intervals)
    

    StatsForecast: Classical Methods at Scale

    The StatsForecast library from Nixtla warrants particular attention. It provides highly optimised implementations of classical methods (AutoARIMA, ETS, Theta, CES, MSTL) that run 100 to 1,000 times faster than traditional implementations. This speed advantage permits the fitting of individual models per time series across thousands of series, which often yields better results than a single complex model fitted globally.

    from statsforecast import StatsForecast
    from statsforecast.models import (
        AutoARIMA, AutoETS, AutoTheta, MSTL, SeasonalNaive
    )
    
    # Fit multiple models simultaneously across many series
    sf = StatsForecast(
        models=[
            AutoARIMA(season_length=7),
            AutoETS(season_length=7),
            AutoTheta(season_length=7),
            MSTL(season_lengths=[7, 365]),  # Weekly + yearly seasonality
            SeasonalNaive(season_length=7),  # Baseline
        ],
        freq='D',
        n_jobs=-1,  # Parallelize across all CPU cores
    )
    
    # df must have columns: unique_id, ds, y
    forecasts = sf.forecast(df=train_df, h=30)  # 30-day forecast
    

    Gradient Boosting for Time Series: An Underused Practitioner Tool

    An important fact about practical forecasting that often receives insufficient attention is that gradient-boosted decision trees—LightGBM, XGBoost, CatBoost—applied to time-series features often outperform both classical statistical models and deep learning on tabular-structured forecasting problems. This approach, sometimes referred to as “ML forecasting” or “feature-based forecasting,” operates by converting the time-series problem into a supervised regression problem.

    The decisive step is feature engineering: instead of feeding raw time-series values to the model, the practitioner constructs features that capture temporal patterns:

    import lightgbm as lgb
    import pandas as pd
    import numpy as np
    
    def create_time_features(df, target_col='y', lags=[1, 7, 14, 28]):
        """Create temporal features for gradient boosting."""
        result = df.copy()
    
        # Calendar features
        result['dayofweek'] = result['ds'].dt.dayofweek
        result['month'] = result['ds'].dt.month
        result['dayofyear'] = result['ds'].dt.dayofyear
        result['weekofyear'] = result['ds'].dt.isocalendar().week.astype(int)
        result['is_weekend'] = (result['dayofweek'] >= 5).astype(int)
    
        # Lag features (past values)
        for lag in lags:
            result[f'lag_{lag}'] = result[target_col].shift(lag)
    
        # Rolling statistics
        for window in [7, 14, 30]:
            result[f'rolling_mean_{window}'] = (
                result[target_col].shift(1).rolling(window).mean()
            )
            result[f'rolling_std_{window}'] = (
                result[target_col].shift(1).rolling(window).std()
            )
    
        # Expanding mean (long-term average up to current point)
        result['expanding_mean'] = result[target_col].shift(1).expanding().mean()
    
        return result.dropna()
    
    features_df = create_time_features(df)
    feature_cols = [c for c in features_df.columns if c not in ['ds', 'y']]
    
    model = lgb.LGBMRegressor(
        n_estimators=1000,
        learning_rate=0.05,
        num_leaves=31,
        subsample=0.8,
    )
    model.fit(features_df[feature_cols], features_df['y'])
    

    The reason this approach is effective is that gradient boosting captures complex nonlinear relationships between features—including interactions among calendar effects, lagged values, and rolling statistics that linear models cannot represent. Feature engineering renders the temporal structure explicit, allowing tree-based models to discover patterns such as “demand is high on Fridays in December when the previous week’s demand was above average”—patterns that require multiple conditional splits and that ARIMA cannot represent at all.

    Tip: In Kaggle time-series competitions, LightGBM with careful feature engineering has won more forecasting competitions than any deep learning model. The combination is fast to train, easy to interpret (via feature importance), handles missing data natively, and scales well to millions of time series. For a production forecasting system without a clear starting point, LightGBM with temporal features is a strong default.

    The Deep Learning Era: N-BEATS, N-HiTS, and TFT

    N-BEATS: Neural Basis Expansion (2020)

    N-BEATS (Oreshkin et al., 2020) was the first deep learning model to conclusively surpass statistical methods on the M4 competition benchmark—a landmark result. Its architecture is elegantly simple: a deep stack of fully-connected blocks, each producing a partial forecast and a partial backcast (reconstruction of the input). The final forecast is the sum of all blocks’ partial forecasts.

    N-BEATS exists in two variants: a generic architecture in which blocks learn arbitrary basis functions, and an interpretable architecture in which blocks are constrained to learn trend and seasonality components, producing decompositions analogous to those of classical methods but with the expressiveness of deep learning. The interpretable variant is particularly valuable in business settings where stakeholders must understand why the model forecasts what it does.

    N-HiTS: Hierarchical Interpolation (2023)

    N-HiTS (Challu et al., 2023) extends N-BEATS with a multi-rate signal sampling approach. Different blocks in the stack process the input at different temporal resolutions: some blocks focus on long-term trends (downsampled signal), while others focus on short-term fluctuations (full-resolution signal). This hierarchical approach significantly improves long-horizon forecasting accuracy while reducing computational cost by a factor of three to five compared with N-BEATS.

    Temporal Fusion Transformer (2021)

    Temporal Fusion Transformer (TFT) (Lim et al., 2021, Google) is designed for the real-world complexity that pure time-series models ignore: it jointly processes static metadata (store location, product category), known future inputs (holidays, promotions, day of week), and observed past values. TFT uses attention mechanisms to learn which historical time steps are most relevant for each forecast horizon and produces interpretable multi-horizon forecasts with prediction intervals.

    TFT’s architecture includes a variable selection network that learns which input features are most important, providing built-in feature importance that other deep models lack. For multi-horizon forecasting with rich covariate information, TFT remains one of the strongest available models.

    DeepAR: Probabilistic Forecasting at Scale (2020)

    DeepAR (Salinas et al., 2020, Amazon) takes a different approach: it trains a single autoregressive RNN model across all time series in a dataset, learning shared patterns while generating probabilistic (not point) forecasts. DeepAR outputs full probability distributions rather than single values, enabling decision-makers to reason about uncertainty rather than only expected outcomes.

    DeepAR’s “global model” approach is especially powerful when individual series are short or sparse. A new product with only 10 days of sales data benefits from patterns learned across millions of other products. This cold-start capability is essential in retail and e-commerce forecasting.

    PatchTST: When Vision Meets Time Series (ICLR 2023)

    PatchTST (Nie et al., 2023) brought a key insight from computer vision to time-series forecasting. Rather than treating each time step as a separate token (computationally expensive and prone to attention dilution), PatchTST groups consecutive time steps into patches, analogously to the way Vision Transformers (ViT) group image pixels into patches.

    A time series of 512 points, with a patch size of 16, becomes a sequence of 32 tokens, each representing a local temporal pattern. The transformer’s self-attention then operates over these 32 patches rather than 512 individual points, substantially reducing computational cost while preserving the model’s ability to capture long-range dependencies between patches.

    PatchTST also introduced channel-independent processing: in multivariate settings, each variable is processed by the same transformer backbone independently, with shared weights. This counterintuitive choice—ignoring cross-variable correlations—improves generalisation substantially for many datasets, because it prevents the model from overfitting to spurious inter-variable correlations in training data.

    Model Year Architecture Key Innovation Best For
    N-BEATS 2020 Fully connected stacks Basis expansion, interpretable variant Univariate, interpretability needed
    DeepAR 2020 Autoregressive RNN Global model, probabilistic output Many related series, cold start
    TFT 2021 Transformer + variable selection Multi-horizon, rich covariates Complex business forecasting
    N-HiTS 2023 Hierarchical FC stacks Multi-rate signal sampling Long-horizon forecasting
    PatchTST 2023 Patched Transformer Patching + channel independence Long-range multivariate

     

    iTransformer: Inverting the Attention Paradigm (ICLR 2024)

    iTransformer (Liu et al., 2024, Tsinghua) poses a pointed question: whether transformers have been applied to time series incorrectly to date.

    In standard transformer-based forecasting, each time step is a token, and the model applies self-attention across time, with each time step attending to every other time step. The feed-forward layers process individual time-step features, while the attention mechanism captures temporal dependencies.

    iTransformer inverts this arrangement: each variable (channel) becomes a token, and the entire time series of that variable becomes the token’s embedding. Self-attention now operates across variables, learning which variables are relevant to each other, while the feed-forward layers process temporal patterns within each variable.

    This inversion is highly effective. On standard multivariate benchmarks (ETTh, ETTm, Weather, Electricity, Traffic), iTransformer achieves leading or near-leading results while being simpler to implement than many competitors. The implication is that, for multivariate forecasting, learning cross-variable relationships through attention is more important than learning temporal patterns through attention; temporal patterns can be captured adequately by simpler feed-forward networks.

    # iTransformer conceptual structure (simplified)
    # Standard Transformer: tokens = time steps, embedding = features
    # iTransformer:          tokens = features,   embedding = time steps
    
    import torch.nn as nn
    
    class iTransformerLayer(nn.Module):
        def __init__(self, n_vars, seq_len, d_model):
            super().__init__()
            # Project each variable's full time series into d_model dims
            self.embed = nn.Linear(seq_len, d_model)  # Per-variable
    
            # Attention operates ACROSS variables (not time)
            self.attention = nn.MultiheadAttention(d_model, nhead=8)
    
            # FFN processes temporal patterns within each variable
            self.ffn = nn.Sequential(
                nn.Linear(d_model, d_model * 4),
                nn.GELU(),
                nn.Linear(d_model * 4, d_model),
            )
    
        def forward(self, x):
            # x: (batch, seq_len, n_vars)
            # Transpose to (batch, n_vars, seq_len), embed
            x = x.permute(0, 2, 1)           # (B, V, T)
            x = self.embed(x)                 # (B, V, D)
            x = x.permute(1, 0, 2)           # (V, B, D) for attention
            attn_out, _ = self.attention(x, x, x)  # Cross-variable attention
            x = x + attn_out
            x = x + self.ffn(x)              # Temporal pattern refinement
            return x
    

    Foundation Models: Zero-Shot Forecasting Arrives

    The paradigm shift that has drawn the most attention in the forecasting community is the emergence of foundation models capable of forecasting time series on which they were never trained. This capability is analogous to GPT’s ability to answer questions on topics it was not explicitly fine-tuned for: the model has learned general patterns of sequential data from substantial pre-training and applies those patterns to new inputs at inference time.

    TimesFM (Google, 2024)

    The first TimesFM release (1.0) was a 200M-parameter decoder-only transformer pre-trained on approximately 100 billion time points from Google Trends, Wikipedia page views, synthetic data, and various public datasets. Its architecture uses input patching (similar to PatchTST) with variable patch sizes, allowing it to handle different granularities and frequencies. The line has since advanced quickly: TimesFM 2.0 scaled to 500M parameters, and TimesFM 2.5 (released September 2025) returned to a 200M-parameter decoder while extending the context window to 16,000 points and adding an optional quantile head for probabilistic output.

    TimesFM’s headline property is strong zero-shot performance: on datasets it has never previously seen, the published evaluation reports accuracy approaching that of models trained specifically on each target dataset across the Monash and Darts benchmark collections, without a single gradient update on the target data. The practical appeal is that a single pre-trained checkpoint can be pointed at a new series and produce a usable forecast immediately.

    import timesfm
    
    # Load the pre-trained model
    tfm = timesfm.TimesFm(
        hparams=timesfm.TimesFmHparams(
            backend="gpu",
            per_core_batch_size=32,
            horizon_len=128,
        ),
        checkpoint=timesfm.TimesFmCheckpoint(
            huggingface_repo_id="google/timesfm-1.0-200m-pytorch"
        ),
    )
    
    # Zero-shot forecast — no training required
    point_forecast, experimental_quantile_forecast = tfm.forecast(
        inputs=[historical_series_1, historical_series_2],  # List of arrays
        freq=[0, 0],  # 0=high-freq, 1=medium, 2=low
    )
    # Returns forecasts for all input series simultaneously
    

    Chronos (Amazon, 2024)

    Chronos tokenises continuous time-series values into discrete bins using mean scaling and quantisation, then applies a T5 language model architecture. By treating forecasting as a language problem—predicting the next token given the sequence so far—Chronos uses decades of NLP architecture innovations and training procedures.

    Chronos offers multiple sizes (20M to 710M parameters) and produces probabilistic forecasts natively, with each prediction representing a distribution over possible future values. The model is well suited to applications where uncertainty quantification matters (inventory planning, risk management, resource allocation).

    A noteworthy feature is synthetic data augmentation during pre-training. Chronos generates millions of synthetic time series using Gaussian processes with diverse kernels, ensuring that the model has been exposed to a wide range of temporal patterns—seasonal, trending, noisy, smooth, and multi-scale—even where the real-world training data does not cover all of them.

    Moirai (Salesforce, 2024)

    Moirai (Woo et al., 2024) is a universal forecasting model designed to handle any time series regardless of frequency, number of variables, or forecast horizon. Its architecture addresses a key limitation of other foundation models: distribution shift across datasets.

    Different time series have radically different scales and statistical properties. Server CPU usage ranges from 0 to 100%. Stock prices range from 1 to 5,000 USD. Energy consumption may be measured in megawatts. Moirai uses a mixture distribution output—predicting parameters of a mixture of distributions rather than point values—that adapts naturally to different scales and distributional shapes without manual normalisation.

    Moirai also introduces Any-Variate Attention, which allows the model to process multivariate time series with arbitrary numbers of variables at inference time, even when the model was pre-trained on series of different dimensionality. This flexibility makes Moirai one of the most versatile foundation models available.

    TimeMixer++ and TSMixer (2024-2025)

    TSMixer (Google, 2023) demonstrated that a simple MLP-Mixer architecture, alternating between time-mixing (across time steps) and feature-mixing (across variables), achieves results competitive with transformers while being significantly faster. TimeMixer++ extends this with multi-scale decomposition, processing different frequency components through separate mixing paths.

    These mixer-based architectures are particularly attractive for production deployment because their computational complexity scales linearly with sequence length (rather than quadratically as in standard attention), which makes them practical for very long context windows and high-frequency data.

    Foundation Model Organization Parameters Open Source Output Type Multivariate
    TimesFM Google 200M Yes Point + quantiles Per-channel
    Chronos Amazon 20M–710M Yes Probabilistic Per-channel
    Moirai Salesforce 14M–311M Yes Mixture distribution Native multivariate
    MOMENT CMU 40M–385M Yes Point Per-channel
    TimeGPT Nixtla Undisclosed No (API) Point + intervals Per-channel
    Timer Tsinghua 67M Yes Autoregressive Per-channel

     

    Caution: Foundation model hype is real, but so are their limitations. Most foundation models process each variable independently (per-channel) and do not capture cross-variable correlations. For problems in which inter-variable relationships are critical (for example, predicting energy demand from weather, price, and grid load), a trained multivariate model such as TFT or iTransformer may still outperform. Foundation models also struggle with domain-specific patterns they have not encountered in pre-training: a financial time series with quarterly earnings seasonality may not be well represented in pre-training data dominated by daily and weekly patterns.

    Benchmarks: How Models Actually Compare

    The most widely used benchmarks for long-term forecasting are the ETT datasets (Electricity Transformer Temperature), Weather, Electricity, and Traffic. The following table presents representative results using Mean Squared Error (MSE), where lower values are better, on standard prediction horizons.

    Model ETTh1 (96) ETTh1 (720) Weather (96) Electricity (96) Traffic (96)
    ARIMA 0.423 0.618 0.284 0.227 0.662
    N-HiTS 0.384 0.464 0.166 0.169 0.415
    PatchTST 0.370 0.449 0.149 0.129 0.370
    iTransformer 0.355 0.434 0.141 0.126 0.360
    TimesFM (zero-shot) 0.391 0.478 0.168 0.155 0.410
    Chronos-Base (zero-shot) 0.398 0.491 0.172 0.160 0.425

     

    Model Family Trade-offs: Statistical vs Deep Learning vs Foundation Statistical Deep Learning Foundation Models Accuracy Training Data needs Interpretability Uncertainty Cold start Good (univariate) Seconds Minimal High Native (ETS) Weak Best (multivariate) Hours–days Large dataset Medium (TFT) Requires setup Poor Competitive Zero (zero-shot) None (zero-shot) Low Native (Chronos) Excellent

    Numbers are approximate and representative. Lower MSE is better. (96) and (720) denote the forecast horizon length. Results compiled from published papers and reproductions.

    Several patterns emerge from the benchmarks:

    • iTransformer and PatchTST lead among supervised models on most multivariate long-range benchmarks, with iTransformer holding a slight edge on datasets in which cross-variable correlations are important.
    • Foundation models (zero-shot) are competitive but do not yet surpass trained models. TimesFM and Chronos typically fall between classical methods and the best supervised deep models, which is notable given the absence of training but not dominant. The gap narrows on datasets whose patterns are well represented in pre-training data.
    • Classical methods remain surprisingly strong on univariate series, particularly when combined with ensembling (averaging forecasts from AutoARIMA, ETS, and Theta). The overhead of deep learning is not always justified.
    • The performance gap widens at longer horizons. The advantage of deep models over classical methods is largest at prediction horizons of 336 steps or more, where complex temporal patterns compound and the assumptions of statistical models break down.

    Practical Model Selection Guide

    Given this landscape, how should a practitioner choose the right model for a given problem? The following decision framework draws on practical constraints:

    Scenario 1: Quick deployment with no training-data infrastructure

    Use: Foundation model (Chronos or TimesFM) in zero-shot mode

    When forecasts are required immediately and investment in a training pipeline is not feasible, foundation models deliver competitive accuracy with no setup. Install the library, feed in the data, and obtain forecasts. This option is well suited to proofs of concept, new data streams, and situations in which the cost of deploying a custom model exceeds the cost of slightly reduced accuracy.

    Scenario 2: Thousands of univariate series, where speed and reliability are required

    Use: StatsForecast (AutoARIMA + AutoETS + AutoTheta ensemble)

    For large-scale retail demand forecasting, financial time series, or IoT monitoring in which each series is relatively independent, fitting per-series statistical models is fast, reliable, and often the most accurate approach. StatsForecast’s optimised implementations make this feasible even for millions of series.

    Scenario 3: Multivariate with rich covariates (promotions, holidays, metadata)

    Use: Temporal Fusion Transformer or LightGBM with temporal features

    When the forecast depends on external factors—promotional calendars, weather forecasts, economic indicators, or product attributes—a model that ingests covariates natively is required. TFT handles this elegantly with built-in variable selection. LightGBM with engineered features is faster to iterate and often equally accurate.

    Scenario 4: Long-horizon multivariate forecasting where accuracy is paramount

    Use: iTransformer or PatchTST

    For applications in which prediction accuracy directly affects high-value decisions (energy trading, infrastructure capacity planning, financial risk management), investment in training a supervised deep model on historical data is appropriate. iTransformer and PatchTST represent the current accuracy frontier for long-range multivariate forecasting.

    Scenario 5: Uncertainty quantification is critical

    Use: Chronos (probabilistic) or DeepAR

    When prediction intervals are required rather than only point forecasts, Chronos provides calibrated probabilistic forecasts out of the box, and DeepAR produces full probability distributions trained on the user’s specific data. These methods are essential for inventory optimisation (balancing stockout against overstock risk) and financial risk management.

    Tip: The most consistently effective practical advice for forecasting accuracy is to ensemble. Averaging forecasts from three to five diverse models (a statistical model, a gradient boosting model, and a deep learning model) consistently outperforms any individual model. The M-series competitions have demonstrated this repeatedly. Ensembling is unglamorous, but it produces better results than almost any other practice.

    Implementation: End-to-End Forecasting Pipeline

    A complete forecasting pipeline involves far more than model selection. The architecture used in production systems is as follows:

    # Production forecasting pipeline using NeuralForecast + StatsForecast
    from neuralforecast import NeuralForecast
    from neuralforecast.models import NHITS, PatchTST, TimesNet
    from statsforecast import StatsForecast
    from statsforecast.models import AutoARIMA, AutoETS, AutoTheta
    import pandas as pd
    import numpy as np
    
    # Step 1: Data preparation
    # df must have columns: unique_id, ds, y
    train_df = df[df['ds'] < '2026-01-01']
    test_df = df[df['ds'] >= '2026-01-01']
    horizon = 30  # 30-day forecast
    
    # Step 2: Statistical models (fast, per-series)
    sf = StatsForecast(
        models=[
            AutoARIMA(season_length=7),
            AutoETS(season_length=7),
            AutoTheta(season_length=7),
        ],
        freq='D',
        n_jobs=-1,
    )
    stat_forecasts = sf.forecast(df=train_df, h=horizon)
    
    # Step 3: Deep learning models (slower, more expressive)
    nf = NeuralForecast(
        models=[
            NHITS(
                input_size=180,
                h=horizon,
                max_steps=1000,
                n_pool_kernel_size=[4, 4, 4],
            ),
            PatchTST(
                input_size=512,
                h=horizon,
                max_steps=1000,
                patch_len=16,
            ),
        ],
        freq='D',
    )
    nf.fit(df=train_df)
    neural_forecasts = nf.predict()
    
    # Step 4: Ensemble (simple average — often the best approach)
    combined = stat_forecasts.merge(neural_forecasts, on=['unique_id', 'ds'])
    model_cols = [c for c in combined.columns
                  if c not in ['unique_id', 'ds']]
    combined['ensemble'] = combined[model_cols].mean(axis=1)
    
    # Step 5: Evaluate
    from utilsforecast.losses import mae, mse, smape
    evaluation = {
        'MAE': mae(test_df['y'], combined['ensemble']),
        'MSE': mse(test_df['y'], combined['ensemble']),
        'sMAPE': smape(test_df['y'], combined['ensemble']),
    }
    print(f"Ensemble performance: {evaluation}")
    

    End-to-End Forecasting Pipeline Historical Data Clean · Validate Feature Engineering Lags · Calendar · Covariates Model(s) Statistical · ML · DL Foundation · Ensemble Forecast Output Point · Intervals · Dist. Evaluation MAE · MSE · sMAPE Backtesting · Monitoring Continuous monitoring → retrain on drift

    Important pipeline components beyond the model include:

    • Data quality checks. Missing values, duplicates, timezone inconsistencies, and outliers in training data directly degrade forecast quality. Automated data validation before model training is essential. If the time-series data originates from InfluxDB, an InfluxDB-to-Iceberg pipeline with Telegraf can centralise and validate data before it reaches the models.
    • Cross-validation for time series. Random train-test splits should never be used for time series. Use expanding-window or sliding-window cross-validation that respects temporal ordering. The utilsforecast library provides optimised implementations.
    • Forecast reconciliation. When forecasts exist at multiple hierarchical levels (store, region, national), they must be coherent: the sum of store forecasts should equal the regional forecast. Methods such as MinTrace reconciliation ensure consistency.
    • Backtesting and monitoring. Production forecasts must be continuously evaluated against actuals. Forecast accuracy that degrades over time, owing to concept drift, data pipeline issues, or regime changes, requires automated detection and model-retraining triggers.

    The Future of Forecasting

    Time-series forecasting sits at an interesting juncture. Classical methods remain competitive for many problems. Deep learning models set the accuracy frontier for complex, multivariate, long-horizon tasks. Foundation models promise to make forecasting more broadly accessible by eliminating the need for per-dataset training. Meanwhile, gradient boosting consistently outperforms both on many real-world, feature-rich problems. For teams building production systems, pairing forecasting with Apache Kafka for multivariate time-series streaming provides the real-time data backbone these models require.

    Several trends will shape the next wave of innovation:

    Foundation model fine-tuning is bridging the gap between zero-shot and fully supervised performance. The pattern is to pre-train on billions of diverse time points and then fine-tune on a specific domain with as few as a few hundred data points. Early results indicate that fine-tuned Chronos and TimesFM can match or exceed fully supervised models using only a fraction of the training data.

    Conformal prediction for calibrated uncertainty is replacing ad hoc prediction interval methods. Conformal prediction provides distribution-free, mathematically guaranteed coverage intervals: when 95% intervals are requested, they contain the true value 95% of the time, regardless of the underlying data distribution. Libraries such as MAPIE and EnbPI make this practical for production use.

    LLM-enhanced forecasting is an emerging research direction in which large language models augment numerical forecasts with textual context. A model that incorporates information such as “Black Friday is next week” or “a competitor has announced a price cut”—information present in text but not in numerical time-series history—can produce forecasts that purely numerical models cannot match. Early papers from Amazon and Google report promising results for retail demand forecasting.

    Real-time adaptive models that continuously update their parameters as new data arrives (online learning) are becoming practical for streaming applications. Rather than periodic batch retraining, the model learns from each new observation in real time, automatically adapting to concept drift without human intervention.

    The most important practical lesson from the current landscape is that the best forecasting system is not the best model but the best pipeline. Data quality, feature engineering, cross-validation, ensembling, monitoring, and retraining together determine forecast accuracy more than any individual model choice. Teams that invest in pipeline infrastructure consistently outperform teams that chase the latest model architecture. The recommended approach is to begin with a simple, well-engineered pipeline and add complexity only when measured accuracy improvements justify it. A seasonal naive baseline should always be used as a reference point, since even the most sophisticated model is of little value if it cannot improve on “same as last week.”


    References

    • Nie, Yuqi, et al. “A Time Series is Worth 64 Words: Long-term Forecasting with Transformers.” (PatchTST) ICLR 2023.
    • Liu, Yong, et al. “iTransformer: Inverted Transformers Are Effective for Time Series Forecasting.” ICLR 2024.
    • Das, Abhimanyu, et al. “A Decoder-Only Foundation Model for Time-Series Forecasting.” (TimesFM) ICML 2024.
    • Ansari, Abdul Fatir, et al. “Chronos: Learning the Language of Time Series.” arXiv:2403.07815, 2024.
    • Woo, Gerald, et al. “Unified Training of Universal Time Series Forecasting Transformers.” (Moirai) ICML 2024.
    • Oreshkin, Boris N., et al. “N-BEATS: Neural Basis Expansion Analysis for Interpretable Time Series Forecasting.” ICLR 2020.
    • Challu, Cristian, et al. “N-HiTS: Neural Hierarchical Interpolation for Time Series Forecasting.” AAAI 2023.
    • Lim, Bryan, et al. “Temporal Fusion Transformers for Interpretable Multi-horizon Time Series Forecasting.” International Journal of Forecasting, 2021.
    • Salinas, David, et al. “DeepAR: Probabilistic Forecasting with Autoregressive Recurrent Networks.” International Journal of Forecasting, 2020.
    • Goswami, Mononito, et al. “MOMENT: A Family of Open Time-Series Foundation Models.” ICML 2024.
    • Wu, Haixu, et al. “TimesNet: Temporal 2D-Variation Modeling for General Time Series Analysis.” ICLR 2023.
    • Taylor, Sean J. and Benjamin Letham. “Forecasting at Scale.” (Prophet) The American Statistician, 2018.
    • Reinsel, David, John Gantz, and John Rydning. “The Digitization of the World: From Edge to Core.” (IDC Data Age 2025 White Paper, sponsored by Seagate) IDC, 2018.
    • NeuralForecast GitHub, Production deep learning forecasting
    • StatsForecast GitHub—Lightning-fast statistical forecasting
    • Time-Series-Library (THU)—Unified deep learning framework
    • Chronos GitHub Repository
    • TimesFM GitHub Repository
  • Time-Series Anomaly Detection in 2026: From Classical Methods to Foundation Models

    Summary

    What this post covers: The full landscape of time-series anomaly detection in 2026, from classical statistical methods through transformer architectures to zero-shot foundation models like TimesFM, Chronos, and MOMENT, with practical guidance on choosing the right model.

    Key insights:

    • Time-series anomaly detection is uniquely hard because “anomalous” is context-dependent, labels are scarce (often less than 0.01% of data), normal behavior drifts over time, and the most dangerous anomalies often manifest only as subtle multivariate correlations.
    • Foundation models pre-trained on 100B+ time points (TimesFM, Chronos) deliver competitive zero-shot anomaly detection without any per-dataset training, collapsing time-to-deployment from weeks to hours.
    • Classical methods (Isolation Forest, Matrix Profile, seasonal decomposition) remain surprisingly competitive and should always be benchmarked as baselines before reaching for deep learning.
    • Different anomaly types (point, contextual, collective, trend, shapelet) require different model architectures, no single model wins across all five categories.
    • The field is now shifting from detection alone toward integrated detect-explain-remediate systems combining LLMs, multimodal foundation models, and edge deployment of distilled detectors.

    Main topics: Why Time-Series Anomaly Detection Is Harder Than Often Assumed, A Taxonomy of Time-Series Anomalies, Classical Approaches: Where It All Started, The Deep Learning Revolution in Anomaly Detection, Transformer-Based Models: The Current Best, Foundation Models for Time Series: The 2025-2026 Frontier, Benchmarks and Real-World Performance, Practical Guide: Choosing the Right Model for the Problem, Implementation: Building an Anomaly Detection Pipeline, Where the Field Is Heading, References.

    On 19 July 2024, a faulty content update from CrowdStrike caused 8.5 million Windows machines to crash simultaneously, producing the largest IT outage in history. Airlines grounded flights, hospitals postponed surgeries, and banks froze transactions. The total economic damage exceeded 10 billion USD. The root cause was a single faulty configuration file pushed to production. An anomaly detection system monitoring the deployment’s telemetry—CPU spikes, crash rates, memory patterns—could have flagged the cascading failure within seconds and triggered an automatic rollback before more than 0.1% of those machines were affected.

    The benefit is not hypothetical. Companies such as Netflix, Uber, and Meta operate real-time anomaly detection systems that identify precisely these patterns: sudden deviations in request latency, error rates, transaction volumes, or system metrics indicating that a problem has arisen before users notice it. The difference between detection in 30 seconds and detection in 30 minutes can be the difference between a minor incident and a high-profile failure.

    Time-series anomaly detection—the task of identifying unusual patterns in sequential, timestamped data—has undergone substantial transformation over the past three years. Classical statistical methods that served practitioners for decades are now being augmented, and in some cases replaced, by deep learning architectures, transformer-based models, and, most recently, pre-trained foundation models that can detect anomalies in time series they have never encountered before, without any task-specific training. The pace of innovation has been notable, and the gap between research results and production performance is narrowing rapidly.

    This guide surveys the full landscape, from classical approaches that remain surprisingly competitive, through the deep learning developments of 2020 to 2024, to the foundation model frontier of 2025 and 2026. For practitioners building anomaly detection for infrastructure monitoring, financial fraud detection, predictive maintenance, or healthcare, understanding these models—their strengths, limitations, and practical trade-offs—is essential.

    Why Time-Series Anomaly Detection Is Harder Than Often Assumed

    Detecting anomalies in tabular data is relatively straightforward: a transaction of 50,000 USD when the customer’s average is 200 USD is clearly unusual. Time-series anomaly detection is fundamentally harder because the definition of “unusual” depends on temporal context: patterns that are normal at one time may be anomalous at another.

    Consider server CPU usage. A spike to 95% utilisation at 3 AM may be entirely normal—it is when the batch processing job runs. The same spike at 3 PM, when only light API traffic is expected, may indicate a runaway process or a denial-of-service attack. A gradual drift from a 40% baseline to 60% over six weeks may indicate a memory leak that will eventually cause a crash. Each of these requires the detection system to understand not only the current value but also its relationship to seasonal patterns, trends, and the broader temporal context.

    The challenges fall into several categories:

    Rarity of labelled anomalies. In most real-world datasets, anomalies represent less than 1% of observations and often less than 0.01%. Supervised learning approaches struggle because the classes are so imbalanced. Most current best methods therefore operate in unsupervised or semi-supervised settings, learning the structure of normal behaviour and flagging deviations.

    Concept drift. The definition of “normal” changes over time. A system that learned normal patterns from January data may flag entirely healthy February patterns as anomalous if the business has grown, the user base has shifted, or the infrastructure has been upgraded. Models must adapt to evolving baselines without losing sensitivity to genuine anomalies.

    Multivariate dependencies. Modern systems generate hundreds or thousands of metrics simultaneously. An anomaly may not be visible in any single metric—CPU appears normal, memory appears normal, disk I/O appears normal—yet the simultaneous combination of all three at slightly elevated levels indicates an emerging problem. Capturing these inter-metric correlations is where deep learning approaches surpass classical univariate methods.

    Key Takeaway: Time-series anomaly detection is difficult because “anomalous” is context-dependent, labelled data is scarce, normal behaviour evolves, and the most consequential anomalies often manifest only as subtle correlations across multiple variables. Models that handle all four challenges simultaneously are rare, which accounts for the continued rapid advancement of the field.

    A Taxonomy of Time-Series Anomalies

    Before selecting a model, a practitioner must identify the type of anomaly under consideration. Different model architectures perform differently across anomaly types:

    Anomaly Type Description Example Best Detection Approach
    Point anomaly A single observation far from expected Sudden CPU spike to 100% Statistical thresholds, Isolation Forest
    Contextual anomaly Normal value in wrong context High traffic at 4 AM (normally low) Seasonal decomposition, LSTM, Transformer
    Collective anomaly A sequence of observations anomalous together Sustained elevated error rate for 10 minutes Sliding-window models, sequence-to-sequence
    Trend anomaly Gradual shift from expected trajectory Memory usage growing 2% weekly (leak) Change-point detection, trend decomposition
    Shapelet anomaly Unusual pattern shape in a subsequence Abnormal ECG waveform morphology Matrix Profile, deep autoencoders

     

    Three Types of Time-Series Anomalies Point Anomaly anomaly time Contextual Anomaly wrong context night (low expected) day Collective Anomaly sustained shift time Normal signal Anomalous segment Point anomaly Contextual anomaly

    Classical Approaches: Where It All Started

    Before deep learning, time-series anomaly detection relied on statistical methods that remain relevant and surprisingly competitive for many use cases. Understanding these foundations is essential: they serve as baselines, they are interpretable, and they run efficiently without GPU infrastructure.

    Statistical and Decomposition Methods

    STL Decomposition with Residual Thresholding. Seasonal-Trend decomposition using LOESS (STL) separates a time series into trend, seasonal, and residual components. Anomalies are identified by flagging residuals that exceed a threshold (typically three standard deviations). The method is simple, interpretable, and handles seasonality well, which makes it well suited to business metrics such as daily active users or hourly revenue.

    ARIMA-based Detection. AutoRegressive Integrated Moving Average models forecast the next value based on historical patterns. Observations that deviate significantly from the forecast are flagged. ARIMA performs well for stationary series with clear autoregressive structure but struggles with complex multi-seasonal patterns or nonlinear dynamics.

    Exponential Smoothing State Space Models (ETS). Similar in spirit to ARIMA but using exponential weighting of past observations. The Holt-Winters variant handles both trend and seasonality and remains a standard tool in production monitoring systems.

    Isolation Forest and Tree-Based Methods

    Isolation Forest (Liu et al., 2008) takes a distinctly different approach. Instead of building a model of normal behaviour and looking for deviations, it directly identifies anomalies by measuring how easy they are to isolate. Anomalous points, being different from the majority, require fewer random partitions to separate from the rest of the data. Isolation Forest is fast, scales well to high-dimensional data, and handles multivariate anomaly detection naturally.

    from sklearn.ensemble import IsolationForest
    import numpy as np
    import pandas as pd
    
    # Create windowed features from raw time series
    def create_features(series, window=24):
        features = []
        for i in range(window, len(series)):
            window_data = series[i-window:i]
            features.append({
                'mean': np.mean(window_data),
                'std': np.std(window_data),
                'min': np.min(window_data),
                'max': np.max(window_data),
                'last': window_data[-1],
                'trend': np.polyfit(range(window), window_data, 1)[0]
            })
        return pd.DataFrame(features)
    
    # Fit Isolation Forest
    features = create_features(cpu_usage_series, window=24)
    model = IsolationForest(contamination=0.01, random_state=42)
    predictions = model.fit_predict(features)
    # -1 = anomaly, 1 = normal
    

    Matrix Profile: Subsequence Analysis

    Matrix Profile (Yeh et al., 2016) computes the distance between every subsequence in a time series and its nearest neighbour, producing a profile of how distinctive each subsequence is. Subsequences with high matrix profile values—those whose nearest neighbour lies unusually far away—are anomalous. Matrix Profile is particularly effective at detecting shapelet anomalies (unusual pattern shapes) and is computationally efficient thanks to the STOMP algorithm, which computes the full matrix profile in O(n² log n) time.

    The Python library stumpy provides production-grade Matrix Profile implementations and remains one of the more underused tools in the anomaly detection practitioner’s repertoire.

    The Deep Learning Revolution in Anomaly Detection

    From approximately 2019 onward, deep learning models began consistently outperforming classical methods on complex, multivariate anomaly detection benchmarks. The central insight is that deep neural networks can learn nonlinear temporal patterns that are invisible to linear statistical models.

    LSTM Autoencoders: The First Deep Success

    The LSTM Autoencoder architecture, consisting of an encoder that compresses a time-series window into a latent representation followed by a decoder that reconstructs the original window, became the first widely adopted deep learning approach for time-series anomaly detection. The model learns to reconstruct normal patterns during training. At inference, windows with high reconstruction error are flagged as anomalous, since the model has not learned to reconstruct those patterns.

    LSTM Autoencoders handle temporal dependencies (the LSTM component) and learn expected patterns (the autoencoder objective) simultaneously. They were the standard deep approach from approximately 2019 to 2022 and remain effective for many applications.

    import torch
    import torch.nn as nn
    
    class LSTMAutoencoder(nn.Module):
        def __init__(self, n_features, hidden_size=64, n_layers=2):
            super().__init__()
            self.encoder = nn.LSTM(
                n_features, hidden_size, n_layers, batch_first=True
            )
            self.decoder = nn.LSTM(
                hidden_size, hidden_size, n_layers, batch_first=True
            )
            self.output_layer = nn.Linear(hidden_size, n_features)
    
        def forward(self, x):
            # Encode: compress the sequence
            _, (hidden, cell) = self.encoder(x)
    
            # Decode: reconstruct the sequence
            seq_len = x.size(1)
            decoder_input = hidden[-1].unsqueeze(1).repeat(1, seq_len, 1)
            decoder_out, _ = self.decoder(decoder_input)
            reconstruction = self.output_layer(decoder_out)
    
            return reconstruction
    
    # Anomaly score = reconstruction error (MSE per window)
    # High reconstruction error → anomaly
    

    GDN and GNN-Based Methods: Modelling Inter-Metric Relationships

    Graph Deviation Network (GDN) (Deng and Hooi, 2021) introduced an elegant solution for multivariate anomaly detection: model the relationships between sensors and metrics as a graph, in which each node is a time series and edges represent learned dependencies. When a metric deviates from what the graph structure predicts based on its neighbours’ values, it is flagged as anomalous.

    GDN’s principal advantage is its ability to identify anomalies that are not visible in individual metrics but manifest as broken inter-metric correlations. For example, in a server cluster, CPU and memory usage typically correlate. If CPU spikes while memory does not, or vice versa, GDN detects the correlation violation, even when both values lie individually within normal ranges.

    USAD: Unsupervised Anomaly Detection

    USAD (Audibert et al., 2020) combines autoencoders with adversarial training. Two decoder networks compete: one reconstructs the input from the latent space, while the other attempts to reconstruct the first decoder’s output. This adversarial scheme requires the autoencoders to learn sharper boundaries between normal and anomalous patterns, significantly improving detection accuracy relative to standard autoencoders. USAD is fast to train, performs well on multivariate data, and has become a popular baseline in academic benchmarks.

    Transformer-Based Models: The Current Best

    The transformer architecture, originally designed for natural language processing, has proven highly effective for time-series analysis. Its self-attention mechanism captures long-range dependencies in sequences without the vanishing gradient problems that limit RNNs and LSTMs. Several transformer-based models have set new state-of-the-art results on anomaly detection benchmarks.

    Anomaly Transformer (ICLR 2022)

    Anomaly Transformer (Xu et al., 2022) introduced a central insight: in normal time-series data, each point’s attention pattern should focus on adjacent points (the “prior-association”) and on semantically similar points elsewhere in the series (the “series-association”). These two association patterns align for normal data but diverge for anomalies. Anomaly Transformer introduces an Association Discrepancy metric that measures this divergence, providing a principled anomaly score.

    The model achieved leading results on six benchmark datasets at the time of publication and remains among the strongest methods for unsupervised multivariate anomaly detection. Its principal contribution—using attention-pattern discrepancy rather than reconstruction error as the anomaly score—represents a conceptual advance over prior autoencoder-based approaches.

    DCdetector: Dual-Attention Contrastive Learning (ICML 2023)

    DCdetector (Yang et al., 2023) builds on the association discrepancy idea with a contrastive learning framework. It creates two representations of each time step, one from a “patch-wise” attention view and one from a “channel-wise” attention view, and uses contrastive learning to maximise agreement for normal patterns and divergence for anomalies. DCdetector achieved new state-of-the-art results on multiple benchmarks, improving on Anomaly Transformer’s F1 scores by 2 to 5 points on several datasets.

    TimesNet: From Temporal to Spatial (ICLR 2023)

    TimesNet (Wu et al., 2023) takes a creative approach: it transforms 1D time-series data into 2D representations by reshaping each period (daily, weekly, and so on) into a 2D image-like tensor, and then applies 2D convolutional neural networks to capture both intra-period and inter-period patterns simultaneously. This transformation allows TimesNet to use the feature extraction capabilities of CNNs, originally developed for computer vision, on temporal data.

    TimesNet is a general-purpose time-series model (it handles forecasting, classification, and anomaly detection), and its multi-task capability makes it a strong choice for teams that require a single architecture for multiple analytical needs.

    Model Year Core Idea Strengths Limitations
    LSTM Autoencoder 2019 Reconstruct normal patterns Simple, well-understood Limited long-range context
    GDN 2021 Graph-based inter-metric modeling Catches correlation anomalies Complex graph construction
    Anomaly Transformer 2022 Attention association discrepancy Strong benchmark results Computationally expensive
    TimesNet 2023 1D→2D transformation + CNN Multi-task capable Assumes periodic structure
    DCdetector 2023 Dual-attention contrastive learning SOTA on multiple benchmarks Requires careful tuning

     

    Foundation Models for Time Series: The 2025-2026 Frontier

    The most consequential development in time-series analysis over the past two years has been the emergence of foundation models—large, pre-trained models capable of performing time-series tasks, including anomaly detection, on data they have never previously seen, without task-specific training. This represents the same paradigm shift that GPT introduced to language and CLIP introduced to vision: train once on substantial diverse data, then apply to arbitrary downstream tasks via fine-tuning or zero-shot inference.

    TimesFM (Google, 2024)

    TimesFM (Time Series Foundation Model), developed by Google Research, was pre-trained on approximately 100 billion time points from diverse sources, including financial markets, weather stations, energy consumption, web traffic, and synthetic data. At 200 million parameters, TimesFM is designed as a decoder-only transformer that generates point forecasts. Anomaly detection is achieved by flagging observations that deviate significantly from the model’s zero-shot forecast.

    TimesFM’s notable property is that it produces competitive forecasts, and therefore competitive anomaly detection, without exposure to the user’s specific data during training. A practitioner provides a time series, the model generates a forecast based on patterns learned from 100 billion diverse time points, and the actuals are compared against the forecasts. This zero-shot capability removes the need for per-dataset model training and substantially reduces time-to-deployment for new monitoring use cases.

    Chronos (Amazon, 2024)

    Chronos (Ansari et al., 2024), from Amazon, takes an innovative approach: it tokenises time-series values into discrete bins (analogous to how language models tokenise words) and then applies a standard language model architecture (T5) to the tokenised sequence. This allows Chronos to use production-proven language model architectures and training procedures for time-series tasks.

    Chronos offers multiple model sizes (Mini: 20M, Small: 46M, Base: 200M, Large: 710M parameters) and performs well in zero-shot evaluations. For anomaly detection, the approach is forecast-based: Chronos generates probabilistic forecasts, and observations falling outside the prediction intervals are flagged as anomalous.

    import torch
    from chronos import ChronosPipeline
    
    # Load pre-trained Chronos model
    pipeline = ChronosPipeline.from_pretrained(
        "amazon/chronos-t5-base",
        device_map="auto",
        torch_dtype=torch.float32,
    )
    
    # Generate probabilistic forecast (zero-shot — no training needed)
    context = torch.tensor(historical_data)  # Your time series
    forecast = pipeline.predict(
        context,
        prediction_length=24,  # Forecast next 24 steps
        num_samples=100,       # Generate 100 forecast samples
    )
    
    # Anomaly detection via prediction intervals
    median_forecast = forecast.median(dim=1).values
    lower_bound = forecast.quantile(0.025, dim=1).values  # 2.5th percentile
    upper_bound = forecast.quantile(0.975, dim=1).values   # 97.5th percentile
    
    # Points outside the 95% prediction interval are anomalies
    anomalies = (actual_values < lower_bound) | (actual_values > upper_bound)
    

    MOMENT (CMU, 2024)

    MOMENT (Goswami et al., 2024)—Multi-task Open-source pre-trained Model for Every Time series—is a family of models specifically designed for multiple time-series tasks, including anomaly detection, classification, forecasting, and imputation. Unlike TimesFM and Chronos, which approach anomaly detection indirectly through forecasting, MOMENT is explicitly trained with an anomaly detection objective during pre-training.

    MOMENT uses a masked reconstruction objective. During pre-training, random patches of the time series are masked, and the model learns to reconstruct them. For anomaly detection, the reconstruction error at each time step serves as the anomaly score. Observations that the model finds difficult to reconstruct from context—because they deviate from patterns learned across its substantial pre-training dataset—receive high anomaly scores.

    MOMENT is open source, available on Hugging Face, and supports fine-tuning for domain-specific applications. Its anomaly detection performance is competitive with specialised models trained on the target dataset, despite MOMENT requiring no task-specific training.

    Timer and TimeGPT: Commercial and Research Alternatives

    TimeGPT (Nixtla, 2024) is a commercially available foundation model with an API-based interface. Users send time-series data to the API and receive forecasts and anomaly scores without managing any model infrastructure. TimeGPT is attractive for teams that wish to access foundation model capabilities without the complexity of model deployment, though it requires sending data to an external service, which is unacceptable for sensitive applications.

    Timer (Liu et al., 2024), from Tsinghua University, is a generative pre-trained transformer for time series that unifies multiple analytical tasks. It uses an autoregressive next-token prediction objective (analogous to GPT) on tokenised time-series data, and can perform anomaly detection, forecasting, and imputation in a single framework.

    Foundation Model Origin Parameters Open Source Anomaly Approach Key Advantage
    TimesFM Google 200M Yes Forecast-based substantial pre-training data (100B points)
    Chronos Amazon 20M-710M Yes Probabilistic forecast Multiple sizes, LLM architecture
    MOMENT CMU 40M-385M Yes Masked reconstruction Explicit anomaly detection objective
    TimeGPT Nixtla Undisclosed No (API) Forecast-based Zero infrastructure, API-ready
    Timer Tsinghua 67M Yes Autoregressive GPT-style unified framework

     

    Model Category Comparison: Statistical vs ML vs Deep Learning Statistical Methods ML Methods Deep Learning Examples STL, ARIMA, ETS Examples Isolation Forest, Matrix Profile Examples LSTM AE, GDN, Anomaly Transformer Training Data Minimal—days of history Training Data Moderate—weeks of history Training Data Large, months of normal data Multivariate Limited (univariate focus) Multivariate Yes (feature engineering) Multivariate Native (learns correlations) Accuracy good for simple series Accuracy strong baseline Accuracy best on complex data

    Tip: Foundation models perform particularly well when anomaly detection must be deployed quickly on new, unseen time series without first collecting training data. If abundant historical data with labelled anomalies is available for the relevant domain, a fine-tuned specialised model (such as Anomaly Transformer or DCdetector) may still outperform zero-shot foundation models. The appropriate choice depends on whether the principal constraint is labelled-data availability or model performance ceiling.

    Benchmarks and Real-World Performance

    The academic community evaluates anomaly detection models on several standard benchmark datasets. Understanding these benchmarks, and their limitations, helps calibrate expectations for real-world performance.

    Dataset Domain Dimensions Anomaly % Key Challenge
    SMD Server Machines 38 ~4.2% Multi-entity, diverse patterns
    MSL NASA Spacecraft 55 ~10.7% Telemetry with complex physics
    SMAP NASA Soil Moisture 25 ~13.1% Sensor noise, gradual drifts
    SWaT Water Treatment Plant 51 ~12.1% Cyber-physical attacks, subtle
    PSM eBay Server Metrics 25 ~27.8% High anomaly rate, noisy labels

     

    Caution: A 2022 paper by Kim et al. (“Towards a Rigorous Evaluation of Time-Series Anomaly Detection”) demonstrated that many published benchmark results are inflated by methodology issues, particularly the use of point-adjust (PA) metrics that credit models for detecting any point within an anomaly segment, even when the detection is delayed. Under stricter metrics, the performance gap between methods narrows considerably, and some classical methods perform comparably with deep models. Models should always be evaluated on the practitioner’s own data using metrics that reflect operational requirements, including detection latency and the false positive rate at a target recall.

    Practical Guide: Choosing the Right Model for the Problem

    With so many available models, selection can be challenging. The following decision framework draws on real-world constraints:

    Decision Framework

    Is labelled anomaly data available?

    • Yes (100 or more labelled anomalies): Fine-tune a supervised or semi-supervised model. Consider fine-tuning MOMENT or training DCdetector with the labels guiding threshold selection.
    • No: Use unsupervised methods. Proceed to the next question.

    Is the deployment new, with no historical training data?

    • Yes: Use a foundation model (Chronos, TimesFM, or MOMENT) in zero-shot mode. Competitive detection is available immediately without training.
    • No (ample historical data): Train a specialised model for best performance. Proceed to the next question.

    Is the problem univariate or multivariate?

    • Univariate (single metric): STL decomposition with thresholding is difficult to beat for simplicity and interpretability. For higher accuracy, use Matrix Profile or an LSTM autoencoder.
    • Multivariate (many correlated metrics): Use Anomaly Transformer, DCdetector, or GDN to capture inter-metric correlations.

    What are the latency requirements?

    • Real time (sub-second): Avoid transformer models at inference. Use Isolation Forest, streaming Matrix Profile (via STUMPY), or lightweight LSTM models.
    • Near real time (seconds to minutes): Any model is feasible with appropriate infrastructure.
    • Batch (hourly or daily): Prioritise accuracy over speed. Use the most capable model available.

    Implementation: Building an Anomaly Detection Pipeline

    A production anomaly detection system involves more than the model alone. The full pipeline architecture is as follows:

    Anomaly Detection Pipeline Data Ingestion metrics / logs Pre- processing normalize, fill gaps Detection Model Chronos / MOMENT Anomaly Score recon. error / deviation Threshold Decision calibrate on normal data Alert & Remediate PagerDuty / auto-rollback operator feedback loop (fine-tuning)

    # Complete anomaly detection pipeline with Chronos
    import torch
    import numpy as np
    from chronos import ChronosPipeline
    from dataclasses import dataclass
    from typing import Optional
    
    @dataclass
    class AnomalyResult:
        timestamp: str
        value: float
        expected: float
        lower_bound: float
        upper_bound: float
        anomaly_score: float
        is_anomaly: bool
    
    class TimeSeriesAnomalyDetector:
        def __init__(
            self,
            model_name: str = "amazon/chronos-t5-small",
            context_length: int = 512,
            prediction_length: int = 1,
            confidence_level: float = 0.95,
        ):
            self.pipeline = ChronosPipeline.from_pretrained(
                model_name,
                device_map="auto",
                torch_dtype=torch.float32,
            )
            self.context_length = context_length
            self.prediction_length = prediction_length
            self.alpha = 1 - confidence_level
    
        def detect(
            self,
            history: np.ndarray,
            actual_value: float,
            timestamp: str,
        ) -> AnomalyResult:
            """Detect if actual_value is anomalous given history."""
            # Use last context_length points
            context = torch.tensor(
                history[-self.context_length:]
            ).unsqueeze(0).float()
    
            # Generate probabilistic forecast
            forecast = self.pipeline.predict(
                context,
                prediction_length=self.prediction_length,
                num_samples=200,
            )
    
            # Extract prediction intervals
            median = forecast.median(dim=1).values[0, 0].item()
            lower = forecast.quantile(
                self.alpha / 2, dim=1
            ).values[0, 0].item()
            upper = forecast.quantile(
                1 - self.alpha / 2, dim=1
            ).values[0, 0].item()
    
            # Calculate anomaly score (normalized deviation)
            interval_width = upper - lower
            if interval_width > 0:
                score = abs(actual_value - median) / interval_width
            else:
                score = abs(actual_value - median)
    
            is_anomaly = actual_value < lower or actual_value > upper
    
            return AnomalyResult(
                timestamp=timestamp,
                value=actual_value,
                expected=median,
                lower_bound=lower,
                upper_bound=upper,
                anomaly_score=score,
                is_anomaly=is_anomaly,
            )
    
    # Usage
    detector = TimeSeriesAnomalyDetector()
    result = detector.detect(
        history=cpu_usage_last_7_days,
        actual_value=current_cpu_reading,
        timestamp="2026-04-03T08:15:00Z",
    )
    
    if result.is_anomaly:
        print(f"ANOMALY at {result.timestamp}: "
              f"value={result.value:.1f}, "
              f"expected={result.expected:.1f} "
              f"[{result.lower_bound:.1f}, {result.upper_bound:.1f}]")
    

    Pipeline components beyond the model itself include:

    • Data preprocessing. Handle missing values (forward-fill or interpolation), normalise scales across metrics, and align timestamps across data sources.
    • Threshold calibration. Use a validation period of known-normal data to calibrate anomaly thresholds. A threshold set too low produces a flood of false positives; one set too high misses real incidents.
    • Suppression and deduplication. A single incident may trigger dozens of anomaly alerts across correlated metrics. Group alerts by time window and root cause to avoid alert fatigue.
    • Feedback loop. Operators who acknowledge or dismiss alerts provide implicit labels. This data should be fed back into the model as a fine-tuning signal to improve detection over time.
    • Seasonal awareness. Explicitly model known business cycles (daily patterns, weekend effects, holiday traffic shifts) to reduce false positives during expected but unusual periods.

    Where the Field Is Heading

    Time-series anomaly detection is at an inflection point. The convergence of foundation models, transformer architectures, and practical tooling is making it possible to deploy sophisticated anomaly detection systems with substantially less effort than was the case even two years ago. Whereas a 2022 deployment required collecting domain-specific training data, training a specialised model, and calibrating thresholds through iterative experimentation, a 2026 deployment can begin with a zero-shot foundation model that delivers competitive performance from day one and improves with domain-specific fine-tuning.

    Several trends will shape the next two to three years:

    Multimodal foundation models that jointly reason over time-series metrics, log messages, and trace data are emerging from research laboratories. An anomaly detection system that can correlate a latency spike with a specific error message in the application logs and a deployment event in the change management system would substantially reduce mean time to diagnosis, not merely detection.

    LLM-augmented anomaly explanation represents a further frontier. Current systems indicate that something is anomalous but rarely explain why. Integrating LLMs that can explain anomaly detections in natural language (“CPU spiked to 95% at 3:14 PM, coinciding with a deployment of version 2.4.1 to the payment service; the historical pattern suggests a connection between this deployment and similar spikes”) would close the gap between detection and remediation.

    Edge deployment of lightweight anomaly detection models is becoming practical as foundation model distillation techniques improve. Running a compact anomaly detector directly on IoT devices, industrial sensors, or network routers, without round-tripping data to a cloud service, enables real-time detection with lower latency and improved data privacy.

    The field has moved from the question “can anomalies be detected automatically?” (yes, reliably, since the late 2010s) to “can anomalies be detected without per-dataset training?” (yes, with foundation models, since 2024). The current frontier is whether anomalies can be detected, explained, and accompanied by suggested remediation, all in real time. That question is being actively answered, and the pace of progress suggests it will not remain open for long.


    References

    • Xu, Jiehui, et al. “Anomaly Transformer: Time Series Anomaly Detection with Association Discrepancy.” ICLR 2022.
    • Yang, Yiyuan, et al. “DCdetector: Dual Attention Contrastive Representation Learning for Time Series Anomaly Detection.” ICML 2023.
    • Wu, Haixu, et al. “TimesNet: Temporal 2D-Variation Modeling for General Time Series Analysis.” ICLR 2023.
    • Ansari, Abdul Fatir, et al. “Chronos: Learning the Language of Time Series.” arXiv:2403.07815, 2024.
    • Das, Abhimanyu, et al. “A Decoder-Only Foundation Model for Time-Series Forecasting.” (TimesFM) ICML 2024.
    • Goswami, Mononito, et al. “MOMENT: A Family of Open Time-Series Foundation Models.” ICML 2024.
    • Deng, Ailin, and Bryan Hooi. “Graph Neural Network-Based Anomaly Detection in Multivariate Time Series.” AAAI 2021.
    • Audibert, Julien, et al. “USAD: UnSupervised Anomaly Detection on Multivariate Time Series.” KDD 2020.
    • Kim, Siwon, et al. “Towards a Rigorous Evaluation of Time-Series Anomaly Detection.” AAAI 2022.
    • Liu, Fei Tony, Kai Ming Ting, and Zhi-Hua Zhou. “Isolation Forest.” ICDM 2008.
    • Yeh, Chin-Chia Michael, et al. “Matrix Profile I: All Pairs Similarity Joins for Time Series.” ICDM 2016.
    • Time-Series-Library (THU)—Unified framework for time-series models including anomaly detection
    • Amazon Chronos GitHub Repository
    • MOMENT GitHub Repository
  • Docker Containers Explained: From Development to Production

    The short version

    Docker’s defining move is to make the runtime environment part of the shipped artifact. Because a container carries its own filesystem, libraries, and configuration while sharing the host kernel, it starts in milliseconds with megabytes of overhead—close enough to a bare process that running dozens of isolated services on one host, creating a fresh environment for every pull request, and deploying by replacing containers all become routine rather than exceptional. The same property eliminates most “works on my machine” defects at their source instead of patching them downstream.

    The material that follows moves from that reasoning into working practice: the distinction between images, containers, and registries; writing a Dockerfile that caches well and runs as a non-root user; wiring several services together with Compose; how container networking and volumes actually behave; and the production adjustments—multi-stage builds, pinned versions, health checks, resource limits—that a development setup leaves out. Because containers are deliberately ephemeral, persistent state has to live in volumes or an external database. A closing section on debugging covers the single habit that resolves most container failures, which is to read the logs first: a missing environment variable or an unreachable dependency is usually named there outright.

    The problem Docker set out to solve is older than Docker by decades: software that runs correctly on the machine where it was written behaves differently, or fails outright, on the machine where it has to run. Code that works on a developer’s laptop breaks in staging; an application that is stable in staging misbehaves in production; a new engineer loses days assembling a local environment that never quite matches the cloud target. Each of these failures traces back to the same root cause, which is that the environments in which code executes differ in ways that stay invisible until something breaks.

    A container is a direct answer to that root cause. It packages an application together with its libraries, configuration, and runtime into a single image that behaves identically wherever a compatible kernel is available. An image built on a laptop runs the same way on an Ubuntu server in a cloud region, on a Windows workstation through a Linux backend, or on an ARM single-board computer. Behavior, dependencies, and configuration travel with the artifact instead of being reconstructed at each destination. That property—reproducibility of the runtime environment itself—is what carried containers from a niche Linux facility to a default expectation of professional software delivery within a decade of Docker’s public release in 2013.

    By 2026, familiarity with containers is a baseline expectation for professional developers rather than a specialization. The sections below proceed from the underlying concepts to production-ready patterns, favoring the commands and design decisions that recur in real projects over treating Docker as an abstraction to be admired from a distance.

    Why Docker Changed Software Development Forever

    To understand why Docker matters, one must first understand what it replaced. Before containers, deploying software typically involved one of two approaches.

    Manual server configuration: An operator would connect to a server via SSH and install dependencies by hand, documenting the steps in a README and trusting that subsequent operators would follow them correctly. Engineers would later discover that production was running Python 3.8 while development was using Python 3.11, then spend days tracing the resulting behavioral differences. The approach was slow, error-prone, and impossible to scale.

    Virtual Machines (VMs): Virtual machines address the consistency problem by virtualizing the entire hardware stack. A complete operating system image is packaged and executed inside another operating system. However, virtual machines are heavyweight. A typical image is gigabytes in size and takes minutes to boot. Running fifty isolated services as separate virtual machines requires fifty copies of a full operating system and consumes substantial resources.

    Docker containers take a different approach: rather than virtualizing hardware, they virtualize the operating system. Containers share the host kernel but maintain isolated filesystems, processes, and network interfaces. The result is environments that are isolated like virtual machines yet lightweight like processes. A container starts in milliseconds rather than minutes and incurs overhead measured in megabytes rather than gigabytes.

    This performance profile enables patterns that were impractical with virtual machines: operating fifty isolated microservices on a single server, instantiating ephemeral test environments for every pull request, and deploying code updates by replacing containers rather than executing update scripts. These patterns are now industry standard, and Docker is the technology that made them practical. For example, event-driven architectures based on Apache Kafka for stream processing or Apache Flink for complex event processing rely heavily on containerized deployments to scale individual pipeline stages independently.

    Container vs Virtual Machine: Resource Layers Virtual Machines Physical Hardware Host Operating System Hypervisor Guest OS Libs / Bins App ~GB image · mins to boot Guest OS Libs / Bins App ~GB image · mins to boot Docker Containers Physical Hardware Host OS Kernel (shared) Docker Engine Libs / Bins App ~MB image · ms to start Libs / Bins App ~MB image · ms to start

    Key Takeaway: Docker resolves the “works on my machine” problem by making the machine itself part of the shipped artifact. The container image is simultaneously the packaging mechanism and the guarantee of consistency. The deliverable is not code dispatched in the hope that the destination environment is compatible, but the environment itself.

    Core Concepts: Images, Containers, and Registries

    Docker’s conceptual model rests on three core entities. Conflating them is the most common source of error among newcomers, so each requires precise definition.

    Docker Images: The Blueprint

    A Docker image is a read-only template containing everything required to run an application: the operating system filesystem, application code, libraries, environment variables, and startup commands. An image is built once and can be instantiated as many containers. An image is analogous to a class definition in object-oriented programming—a blueprint rather than the entity itself.

    Images are constructed in layers. Each instruction in a Dockerfile produces a new layer. Layers are cached and reused, so if application code changes but dependencies do not, Docker rebuilds only the modified layers. This layered cache is the reason Docker builds become fast after the initial one.

    Docker Containers: The Running Instance

    A container is a running instance of an image. When an image is executed, Docker creates a writable layer above the image’s read-only layers and starts the specified process. The container possesses an isolated filesystem, network interface, and process namespace. Multiple containers can run concurrently from the same image, each maintaining its own writable state.

    An important property: containers are ephemeral by design. When a container stops, any data written to its filesystem is lost unless stored in a volume, which is discussed later. This ephemerality is a deliberate property rather than a defect. It allows containers to be destroyed and recreated without concern for accumulated state. Persistent data belongs in volumes, and application state belongs in external databases.

    Docker Registries: The Distribution Layer

    A registry is a storage system for Docker images. Docker Hub is the default public registry, hosting hundreds of thousands of community and official images, including Ubuntu, Node.js, PostgreSQL, Redis, and nginx. Private registries such as AWS ECR, Google Artifact Registry, and GitHub Container Registry store proprietary images within an organization’s own infrastructure.

    The workflow is straightforward: an image is built locally, pushed to a registry, and pulled from that registry on any machine that needs to run it. This is how code travels from a developer’s laptop to a production server without manual file copying or SSH-based deployment scripts.

    Docker Architecture: How the Pieces Connect Docker CLI docker run / build REST API Docker Daemon dockerd manages lifecycle Images read-only layers cached + reused run Containers running process isolated + ephemeral Registry Docker Hub ECR · GHCR push / pull Developer interface Orchestration engine Immutable blueprints Live processes Image store

    Writing a First Dockerfile

    A Dockerfile is a text file containing instructions for building a Docker image, with each instruction producing a layer. The following example builds a Python web application image step by step using FastAPI, which is examined in detail in the companion FastAPI guide.

    Docker Development Workflow: Code to Registry Dockerfile FROM · RUN COPY · CMD build Build layer cache fast rebuilds Image immutable tagged artifact run Container live process isolated env push Registry Docker Hub ECR · GHCR pull Production Server same image identical behavior Every environment, dev, staging, production—runs the same image. No more “works on my machine.”

    # Start from an official Python runtime as the base image
    FROM python:3.13-slim
    
    # Set the working directory inside the container
    WORKDIR /app
    
    # Copy dependency files first (for better layer caching)
    COPY requirements.txt .
    
    # Install Python dependencies
    RUN pip install --no-cache-dir -r requirements.txt
    
    # Copy the rest of the application code
    COPY . .
    
    # Create a non-root user for security
    RUN useradd --create-home appuser && chown -R appuser /app
    USER appuser
    
    # Tell Docker what port the app uses (documentation only)
    EXPOSE 8000
    
    # Command to run when container starts
    CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
    

    Several decisions embedded in this Dockerfile are important for production use.

    python:3.13-slim rather than python:3.13: The slim variant omits documentation, build headers, and other non-essential components, reducing the uncompressed image from roughly 1 GB for the full Debian-based tag to approximately 130 MB. (Python 3.13 is the current stable release line as of 2026; the same reasoning applies to whichever version tag a project pins.) Smaller images build faster, transfer faster, and present a smaller attack surface. For practitioners considering a compiled language to produce leaner containers, the Python and Rust comparison examines how Rust’s static binaries can yield single-digit-megabyte images.

    Copying requirements.txt before the application code: Docker rebuilds only the layers that have changed and any layers that follow them. Copying dependencies before source code allows the expensive pip install step to remain cached as long as requirements.txt is unchanged, even when application code changes. The result is substantially faster iterative builds.

    Running as a non-root user: Processes in containers run as root by default. This poses a security risk: an attacker who exploits an application vulnerability obtains root access inside the container. Creating a non-root user and switching to it is a low-effort improvement with meaningful security benefit.

    The image can then be built and executed as follows.

    # Build the image, tagging it as "myapp:latest"
    docker build -t myapp:latest .
    
    # Run the container, mapping host port 8080 to container port 8000
    docker run -p 8080:8000 myapp:latest
    
    # Run in detached mode (background)
    docker run -d -p 8080:8000 --name myapp myapp:latest
    
    # View running containers
    docker ps
    
    # View container logs
    docker logs myapp
    
    # Stop the container
    docker stop myapp
    

    Docker Compose: Multi-Container Applications

    Real applications rarely run in isolation. A typical web application requires a database, a cache, possibly a background worker, and sometimes a reverse proxy. Running and connecting such services manually with docker run commands becomes unmanageable. Docker Compose addresses this by defining and running multi-container applications from a single YAML configuration file.

    The following docker-compose.yml defines a FastAPI application paired with PostgreSQL and Redis.

    services:
      # The web application
      web:
        build: .
        ports:
          - "8000:8000"
        environment:
          DATABASE_URL: postgresql://postgres:secret@db:5432/appdb
          REDIS_URL: redis://redis:6379/0
        depends_on:
          db:
            condition: service_healthy
          redis:
            condition: service_started
        volumes:
          - ./src:/app/src  # Mount source for hot reload in development
    
      # PostgreSQL database
      db:
        image: postgres:18-alpine
        environment:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: secret
          POSTGRES_DB: appdb
        volumes:
          - postgres_data:/var/lib/postgresql/data  # Persist data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U postgres"]
          interval: 5s
          timeout: 5s
          retries: 5
    
      # Redis cache
      redis:
        image: redis:8-alpine
        volumes:
          - redis_data:/data
    
    # Named volumes persist data between container restarts
    volumes:
      postgres_data:
      redis_data:
    

    Several patterns in this configuration warrant attention.

    Service discovery by name: The web service connects to the database using db as the hostname, visible in DATABASE_URL: postgresql://...@db:5432/.... Docker Compose creates an internal network on which each service is reachable by its service name, removing the need for hardcoded IP addresses.

    Health checks with depends_on: Declaring depends_on: db alone only waits for the database container to start, not for PostgreSQL to be ready to accept connections. Combining condition: service_healthy with a health check ensures the web service does not start until the database is actually responsive.

    Volume mounts for development: Mounting ./src:/app/src ensures that source code changes on the host machine are immediately reflected inside the container, enabling hot reload without rebuilding the image for every change.

    # Start all services (detached)
    docker compose up -d
    
    # View logs from all services
    docker compose logs -f
    
    # View logs from a specific service
    docker compose logs -f web
    
    # Stop all services
    docker compose down
    
    # Stop and remove volumes (WARNING: deletes data)
    docker compose down -v
    
    # Rebuild images after Dockerfile changes
    docker compose up -d --build
    
    # Run a one-off command in a service container
    docker compose exec web python manage.py migrate
    

    Networking: How Containers Communicate

    Docker’s networking model rests on a few concepts that frequently cause confusion among developers encountering container networking for the first time.

    Each container has its own network namespace. Inside a container, localhost refers to the container itself rather than the host machine. This often surprises developers: a web server inside a container cannot connect to a database running on the host using localhost:5432 because the database is not “local” from the container’s perspective.

    Docker Compose creates a default network. All services declared in a docker-compose.yml file are automatically connected to a shared bridge network on which services reach one another by service name. The web service connects to db using the hostname db, not localhost.

    Port publishing exposes containers to the host. The ports: - "8000:8000" syntax publishes container port 8000 on host port 8000. Without this directive, the service is reachable only from within the Docker network and not from a browser on the host machine.

    Internal services should not publish ports in production. A database container does not need to be reachable from outside Docker in production; only the web application requires external access. Omitting port publishing for internal services such as databases, caches, and workers substantially reduces the attack surface.

    Persistent Data with Volumes

    Containers are ephemeral: when a container is removed, its writable layer disappears, and any data written directly to the container filesystem is lost. Databases, file uploads, configuration, and any other data that must survive container restarts require volumes.

    Docker provides two persistence mechanisms.

    Named volumes are managed by Docker and stored in its storage area on the host, typically at /var/lib/docker/volumes/. They are the recommended mechanism for persisting database data because Docker manages their lifecycle independently of any particular container. In the Compose example above, postgres_data and redis_data are named volumes.

    Bind mounts map a specific directory on the host machine to a path inside the container. The ./src:/app/src mount in the development configuration is a bind mount. Changes on the host are immediately visible inside the container. Bind mounts are well suited to development because they enable live code reload, but they are less appropriate for production because they introduce a dependency on the host filesystem structure.

    # List all volumes
    docker volume ls
    
    # Inspect a named volume (shows where data is stored on host)
    docker volume inspect myapp_postgres_data
    
    # Back up a named volume
    docker run --rm \
      -v myapp_postgres_data:/data \
      -v $(pwd):/backup \
      alpine tar czf /backup/postgres_backup.tar.gz /data
    
    # Remove unused volumes (careful — this deletes data!)
    docker volume prune
    

    Production Best Practices: Adjustments for Live Environments

    A Docker configuration that performs well in development can still fail in production in unexpected ways. The gap between “the application runs in Docker” and “the application runs reliably in production Docker” is bridged by several important practices.

    Multi-Stage Builds: Separating Build from Runtime

    Many applications require build tools that are unnecessary at runtime, including compilers, test frameworks, and build system dependencies. Multi-stage builds allow a heavy build environment to produce artifacts that are then copied into a minimal runtime image.

    # Stage 1: Build stage (can be large)
    FROM node:24 AS builder
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci
    COPY . .
    RUN npm run build  # Produces /app/dist
    
    # Stage 2: Production runtime (minimal)
    FROM node:24-alpine AS production
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci --omit=dev  # Only production dependencies
    COPY --from=builder /app/dist ./dist  # Copy only build output
    USER node
    EXPOSE 3000
    CMD ["node", "dist/server.js"]
    

    The final image contains only the Node.js runtime, production dependencies, and compiled output, with no TypeScript compiler, development dependencies, or source files. In practice this is the difference between an image measured in gigabytes—a full node:24 base is on the order of 1 GB before any application code is added—and one built on the much smaller node:24-alpine runtime, which typically lands in the low hundreds of megabytes. The exact saving depends on the application’s dependency tree rather than any fixed ratio, but the direction is consistent: discarding the build toolchain removes the largest layers.

    Avoiding Secrets in Images

    One of the most common security errors, and a violation of clean code principles, is embedding credentials, API keys, or passwords in a Dockerfile or in the image itself. Docker image layers are readable by anyone with access to the image. Even if the secret is added in one layer and removed in another, it remains accessible in the intermediate layer’s history.

    # WRONG: Secret baked into image
    ENV API_KEY=sk-super-secret-key-12345
    
    # RIGHT: Pass secrets at runtime as environment variables
    # In docker run:
    docker run -e API_KEY="${API_KEY}" myapp
    
    # In Docker Compose with an .env file:
    # .env file (never commit this to git):
    # API_KEY=sk-super-secret-key-12345
    
    # docker-compose.yml:
    # environment:
    #   API_KEY: ${API_KEY}  # Reads from .env file
    

    Container Health Checks in Production

    In production environments that employ container orchestration such as Kubernetes, Docker Swarm, or AWS ECS, the orchestrator requires a mechanism to determine container health. Without a health check, the orchestrator assumes that the container is healthy as long as the process is running, even when the application returns HTTP 500 errors for every request.

    HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
      CMD curl -f http://localhost:8000/health || exit 1
    

    The application should expose a /health endpoint that returns HTTP 200 when it is ready to serve requests and can reach its dependencies. The orchestrator will restart unhealthy containers and direct traffic away from them.

    Resource Limits

    Without resource limits, a misbehaving container can consume all available memory or CPU on a host, starving other services. Memory and CPU limits should always be configured in production.

    services:
      web:
        image: myapp:latest
        deploy:
          resources:
            limits:
              memory: 512M
              cpus: "1.0"
            reservations:
              memory: 256M
              cpus: "0.5"
    

    Common Patterns: Web App, API with Database, Worker Queue

    Pattern 1: Web App with Nginx Reverse Proxy

    It is standard practice in production to run a reverse proxy such as nginx or Caddy in front of the application. The proxy handles SSL termination, static file serving, request buffering, and load balancing, allowing the application server to focus on business logic.

    services:
      nginx:
        image: nginx:alpine
        ports:
          - "80:80"
          - "443:443"
        volumes:
          - ./nginx.conf:/etc/nginx/conf.d/default.conf
          - ./certs:/etc/nginx/certs
        depends_on:
          - web
    
      web:
        build: .
        # Note: NO ports published — only nginx reaches this container
        expose:
          - "8000"
    

    Pattern 2: Background Worker with Celery and Redis

    Long-running tasks such as sending emails, processing images, or generating reports should not block HTTP request handlers. The standard pattern queues these tasks and processes them asynchronously through a worker process.

    services:
      web:
        build: .
        command: uvicorn main:app --host 0.0.0.0 --port 8000
    
      worker:
        build: .  # Same image, different command
        command: celery -A tasks worker --loglevel=info
        depends_on:
          - redis
          - db
    
      redis:
        image: redis:8-alpine
    
      db:
        image: postgres:18-alpine
    

    The web and worker services share the same Docker image but execute different commands. This is a common pattern for Python applications: one image, multiple process types, all defined in a single Compose file.

    Debugging Containers: Diagnosing Failures

    Every Docker practitioner accumulates a set of debugging commands. The following are the most frequently used.

    # Open an interactive shell inside a running container
    docker exec -it container_name bash
    # or if bash isn't available (Alpine-based images):
    docker exec -it container_name sh
    
    # Inspect container details (env vars, mounts, network settings)
    docker inspect container_name
    
    # View real-time resource usage (CPU, memory, network I/O)
    docker stats
    
    # Check what files are different from the base image
    docker diff container_name
    
    # Start a stopped container to investigate its state
    docker start -ai container_name
    
    # Run a debugging container with access to all host namespaces
    docker run -it --rm --privileged --pid=host debian nsenter -t 1 -m -u -n -i sh
    
    # Build with verbose output (shows each layer build step)
    docker build --progress=plain .
    
    # Check why a layer is cache-busting (useful for slow builds)
    docker history myapp:latest
    

    The most common debugging scenario is a container that exits immediately after starting. The remedy is to run it interactively in order to surface the error.

    # Override the CMD to drop into a shell instead of running the app
    docker run -it --rm myapp:latest bash
    
    # Or check the logs of an exited container
    docker logs container_name
    
    Tip: The most common cause of “container exits immediately” is an application crash on startup, a missing environment variable, an unreachable database, or a configuration error. Always run docker logs container_name first. The crash output is almost always present and identifies the precise failure.

    From Development to Production: A Mental Model

    Docker’s value lies not in any single feature but in the consistency it establishes across the entire software delivery lifecycle. The same image that runs on a developer’s laptop is the image that is tested in continuous integration and deployed to production. The environment, comprising the operating system, libraries, and configuration structure, is defined once in a Dockerfile and reproduced exactly across all targets.

    The conceptual shift that Docker enables is the treatment of infrastructure as code. The Dockerfile is a precise, version-controlled specification of the application’s runtime environment. The docker-compose.yml is a precise, version-controlled specification of how services connect. Both reside in the repository, are reviewed in pull requests in accordance with Git and GitHub best practices, and are reproduced identically by any developer on the team within minutes through docker compose up.

    This consistency eliminates entire categories of defects, simplifies onboarding considerably, and renders the deployment pipeline reliable in ways that manual server configuration could not achieve. These factors explain why Docker adoption progressed from zero to ubiquitous in under a decade. The tool addressed real problems that developers encountered daily, and the developer experience was favorable.

    The path from this point to production-ready containers is short: learn the Dockerfile instructions, understand how Compose wires services together, keep the debugging commands within reach, and apply the production practices set out above. The natural next step is orchestration—scheduling containers across a cluster, handling restarts and rolling updates, and connecting services that no longer share a single host—which is where Kubernetes pods and their networking model take over from Docker Compose. The concepts here are few and the practical return is large; containerizing a single real application remains the most direct way to internalize why the model spread through the industry so quickly.


    References

  • Python vs Rust: Performance, Safety, and When to Use Each

    The short version

    Treating “Python versus Rust” as a contest produces heat and little insight, because the two languages answer different questions. The useful question is which constraint dominates a given problem. When the binding constraint is developer time, Python almost always wins: its ecosystem, dynamic typing and readable syntax make working code appear faster than in nearly any other language. When the constraint is runtime performance, memory footprint, or compile-time safety, Rust is frequently the better instrument, delivering C-level speed and a borrow checker that eliminates use-after-free errors and data races before a program ever runs. On raw CPU-bound code Rust runs roughly 10 to 100 times faster than pure Python, but that gap collapses for data and machine-learning work once Python delegates the arithmetic to NumPy and PyTorch backends written in C, C++ and CUDA. The pattern gaining ground fastest is not a choice at all but a combination: keep orchestration in Python and rewrite the performance-critical fraction in Rust through PyO3, the approach that Polars, Pydantic v2 and Ruff have made mainstream. The sections below work through performance, memory safety, the learning curve, real-world use cases, that hybrid model, and what each language means for a career.

    Python and Rust are frequently discussed as rivals, yet they were built to solve unrelated problems and rarely compete for the same job. Python, released by Guido van Rossum in 1991, was designed to make programming approachable and readable; three decades later it underpins most of modern data science and machine learning. Rust, which Graydon Hoare began at Mozilla in the mid-2000s to make memory-corruption bugs impossible by construction, was designed for the opposite end of the stack, where predictable performance and safety matter more than developer convenience. One optimises for the time a person spends writing code; the other optimises for the behaviour of that code once it runs.

    That difference in purpose is why the “which is better” framing produces so little of value. A more useful comparison asks what each language is genuinely good at, where each becomes a liability, and how the two are increasingly combined in a single system rather than chosen between. The remainder of this guide works through performance, memory safety, learning curve, ecosystem and career considerations with that framing in mind, and closes with a decision procedure a reader can apply to a concrete project.

    The Real Question Is Not “Which Is Better?”

    Whenever the Python-versus-Rust debate surfaces on programming forums, it generates considerable heat and minimal light. Python devotees point to its ecosystem, readability and flexibility. Rust advocates cite its performance, safety guarantees and increasingly rich tooling. Both sides correctly identify their language’s strengths, and both miss the point.

    The correct framing is the following: what is the dominant constraint on the problem?

    If the dominant constraint is developer time, meaning that something must be built quickly, iterated upon rapidly, or used to experiment with different approaches, Python almost always wins. The combination of dynamic typing, an extensive standard library, a substantial third-party ecosystem (the Python Package Index hosts well over 600,000 projects) and readable syntax means that Python developers write working code faster than in virtually any other language.

    If the dominant constraint is runtime performance or memory usage, for example a system that runs on embedded hardware, must process millions of operations per second, or must run in an environment in which garbage collection pauses are unacceptable, Rust is frequently the best available choice. It delivers C-level performance without C’s memory safety hazards.

    If the dominant constraint is reliability and safety, for example software in which crashes or security vulnerabilities have serious consequences (financial systems, medical devices, operating system components), Rust’s compile-time safety guarantees provide assurance that Python cannot match.

    The difficulty is that most developers do not frame the question in this way. They ask “which language should I learn?” or “which language should I use for this project?” without first identifying what actually constrains them. The following sections address that gap.

    Python: Where It Excels and Why

    Python’s principal advantage is its speed-to-insight ratio. From installing Python to writing a working web scraper, a data analysis script or a machine learning model, the time measured in developer hours is lower than for any comparable language. This is not accidental. Python was designed from the outset around the principle that “code is read more often than it is written,” and that philosophy informs every design decision.

    The Ecosystem That Transformed an Industry

    No language feature matters more for Python’s dominance in data science and machine learning than its ecosystem. NumPy, SciPy, Pandas and Matplotlib form the foundation of scientific computing in Python. TensorFlow and PyTorch, the two dominant deep learning frameworks, are Python-first. Scikit-learn, Hugging Face Transformers, LangChain and FastAPI have each fundamentally changed how their respective domains are practised, and all are Python.

    The critical observation about Python’s ecosystem is that the performance-critical code is not actually written in Python. NumPy’s array operations are implemented in C. PyTorch’s tensor operations run in C++ and CUDA. When a developer calls np.dot(a, b) to multiply two large matrices, Python syntax is used to invoke heavily optimised Fortran and C code. Python becomes the orchestration layer, the glue that connects high-performance components, rather than the performance layer itself. This architecture is sometimes termed the “two-language problem,” and it works remarkably well in practice.

    Python in Web Development

    Django, FastAPI and Flask have made Python a first-class web development language. FastAPI has in particular become widely used for building Python APIs, providing automatic OpenAPI documentation generation, native async support and performance approaching that of Node.js for I/O-bound workloads. For data-driven web applications, dashboards, ML-serving APIs and analytics tools, Python’s ability to connect business logic with data processing and a web interface in a single language is a genuine productivity advantage.

    # A complete working FastAPI endpoint in Python
    from fastapi import FastAPI
    from pydantic import BaseModel
    import numpy as np
    
    app = FastAPI()
    
    class PredictionRequest(BaseModel):
        features: list[float]
    
    @app.post("/predict")
    async def predict(request: PredictionRequest):
        # Imagine a trained model here
        score = np.mean(request.features) * 0.5
        return {"prediction": score, "confidence": 0.87}
    

    Twenty lines produce a complete, type-validated, auto-documented REST API endpoint. Python’s expressiveness per line of code is genuinely substantial.

    Where Python Struggles

    Python’s limitations are well known and warrant honest acknowledgement. The Global Interpreter Lock (GIL) has historically meant that Python cannot execute multiple threads in parallel across multiple CPU cores, a significant limitation for CPU-bound concurrent workloads. This is now changing: Python 3.13 introduced an experimental “free-threaded” build that removes the GIL, and Python 3.14, released in October 2025, promoted that build to officially supported status under PEP 779. The constraint is real but softening, with the important caveat that any C extension not yet updated for the free-threaded build silently re-enables the GIL, so ecosystem compatibility is still maturing.

    Raw Python is slow for CPU-intensive operations. A Python loop processing millions of numbers will be 10 to 100 times slower than equivalent C or Rust code. This is usually mitigated by NumPy vectorisation, but it remains a real constraint for algorithms that do not vectorise easily.

    Python’s memory usage is high compared with lower-level languages. A Python list of integers uses approximately 28 bytes per integer, compared with 4 to 8 bytes in a compiled language. For systems processing large volumes of small data items, this overhead accumulates rapidly.

    Rust: A Modern Systems Programming Language

    Rust has achieved what was long considered improbable: a systems programming language that is both memory-safe and does not require a garbage collector. Understanding why this matters requires a brief detour into why memory management is difficult.

    In languages such as C and C++, the programmer is responsible for explicit allocation and deallocation of memory. This grants maximum control but creates an entire category of bugs, including use-after-free errors (using memory after it has been freed), double-free errors (freeing the same memory twice) and buffer overflows (writing beyond the end of an array). These bugs are the root cause of a substantial proportion of security vulnerabilities. The US National Security Agency has estimated that 70 percent of serious security vulnerabilities in recent years can be traced to memory safety issues.

    Languages such as Java, Python, Go and C# address this problem by adding a garbage collector, a runtime process that automatically identifies and frees unused memory. This eliminates memory bugs but introduces unpredictable pauses (the garbage collector must stop the world to collect garbage), higher memory overhead, and limits on deterministic performance, all problematic for real-time systems, operating system kernels and other low-level applications.

    Rust takes a third approach: it enforces memory safety at compile time, through a system called the borrow checker, with zero runtime overhead. If a Rust program compiles, the compiler has proven that it is free of memory safety bugs. No garbage collector is required. No runtime pauses occur. The result is safe, fast code.

    Rust’s Ownership System

    Rust’s memory model is built around three rules that the compiler enforces.

    1. Every value has exactly one owner.
    2. There can be any number of immutable references to a value, or exactly one mutable reference—but not both simultaneously.
    3. When the owner goes out of scope, the value is automatically freed.

    These rules sound straightforward but have substantial implications. They prevent data races, since two threads cannot mutate the same memory simultaneously. They prevent use-after-free bugs, since a reference cannot be used after its owner has freed the value. They prevent an entire class of concurrency bugs that affect C++ and Java programs. The compiler verifies all of this before the program executes.

    // Rust ownership example — this won't compile
    fn main() {
        let s1 = String::from("hello");
        let s2 = s1;  // s1's ownership moves to s2
    
        println!("{}", s1);  // Error: s1 was moved!
        // The compiler catches this at compile time, not runtime
    }
    
    // The correct way — explicitly clone when you need two owners
    fn main() {
        let s1 = String::from("hello");
        let s2 = s1.clone();  // Creates a deep copy
    
        println!("s1 = {}, s2 = {}", s1, s2);  // Works fine
    }
    

    Rust’s Growing Ecosystem

    Rust’s package manager, Cargo, is frequently cited as one of the best dependency management tools in any programming language. Through cargo build, cargo test, cargo doc and cargo fmt, the Rust toolchain handles the complete development workflow with minimal configuration. The crates.io package registry now hosts more than 200,000 crates, and the quality and documentation standards are generally high.

    Major organisations have committed to Rust. The Linux kernel accepted Rust as its second implementation language in 2022, a historic milestone for a language that was then only seven years old. The Android team at Google rewrites security-sensitive components in Rust. Microsoft has been rewriting Windows components in Rust. The White House’s Office of the National Cyber Director explicitly recommended Rust as a memory-safe language for systems programming in its 2024 cybersecurity report.

    Performance: What the Benchmarks Show and Mask

    Benchmark comparisons between Python and Rust are striking. On CPU-intensive workloads, including the sorting of arrays, the computation of Fibonacci sequences and matrix operations in pure code, Rust is typically 10 to 100 times faster than pure Python. In some string processing benchmarks, Rust exceeds Python by 200 times or more.

    The figures can be misleading, however. Few real Python applications run in pure Python for their performance-critical parts. When a data scientist calls NumPy for array operations, the underlying computation runs at near-C speed. When a Python web server handles HTTP requests, I/O operations dominate runtime and the difference between Python and Rust at the application layer is minimal. When a PyTorch model trains on a GPU, the GPU compute time substantially exceeds any CPU overhead from the Python orchestration layer.

    Workload Type Pure Python vs. Rust Python+NumPy vs. Rust Practical Impact
    CPU-bound computation Python 50-200x slower 2-5x slower High for tight loops
    I/O-bound (web/network) ~2-5x slower ~2-5x slower Low (I/O dominates)
    ML training (GPU) Negligible overhead Negligible overhead None (GPU dominates)
    Memory usage 5-20x more memory 2-5x more memory High for constrained envs
    Startup time 100-500ms typical Same High for serverless/CLI
    Real-time latency GC pauses unpredictable Same Critical for real-time systems

     

    Python vs Rust—Feature Comparison Python Rust Score (out of 10) 10 8 6 4 2 Runtime Speed Memory Safety Ecosystem Size Ease of Learning Concur- rency 2 10 4 10 10 5 9 3 4 10 Scores are qualitative—relative strengths, not absolute benchmarks

    Memory Safety: Why Rust’s Approach Matters

    If performance were the sole consideration, C++ would be the obvious choice for high-performance software, since it is faster than Rust on certain benchmarks and has a substantially larger ecosystem. C++ code is, however, notoriously hazardous to write correctly. The Chrome browser team estimates that approximately 70 percent of Chrome’s serious security vulnerabilities are memory safety bugs in C++ code. Microsoft’s Security Response Center reports similar figures for Windows. These are not bugs introduced by careless programmers; they arise from expert C++ developers with years of experience, supported by code review, static analysis tools and extensive testing.

    Rust eliminates this entire class of vulnerability by construction. A Rust program that compiles cannot contain use-after-free bugs, buffer overflows from unchecked indexing (which produce panics rather than undefined behaviour), or data races. For this reason, the Linux kernel project, which had previously refused to admit any language other than C, made an exception for Rust. For the same reason, the Android team uses Rust for new security-sensitive code, and infrastructure that must be both fast and secure, including network proxies, cryptographic libraries and DNS servers, is increasingly written in Rust.

    Key Takeaway: Rust’s memory safety guarantees are not solely a matter of performance or correctness; they concern the economics of security. Every memory safety vulnerability in a production system carries a cost in incident response, patching and reputational damage. Rust trades upfront development friction (working with the borrow checker) for substantially lower downstream operational security risk.

    Memory Management: Python GC vs Rust Ownership Python (Garbage Collector) Heap Memory Object A ref count: 2 Object B ref count: 0 GC frees B Garbage Collector (runtime overhead) Periodic GC pauses; non-deterministic memory release; simpler to write vs Rust (Ownership System) Stack owner: s1 borrow: &s1 scope ends → auto freed Heap Data “hello, world” owned by s1 Freed exactly when owner leaves scope. Zero runtime GC. Compile-time enforced; zero overhead; no GC pauses; steeper to learn

    The Learning Curve: A Measured Assessment

    Rust is difficult to learn. Not in the sense that “the syntax is unusual” or “tutorials are scarce,” but in the sense that the compiler will reject code that any other language would accept, and the developer must fundamentally rethink data management to satisfy it. The borrow checker is intellectually demanding in a manner that has no direct analogue in Python, JavaScript, Java or most other languages that developers commonly know.

    Most developers report that learning Rust comprises three distinct phases.

    1. Phase 1 (Weeks 1 to 4): substantial frustration. The compiler rejects code consistently. Every attempt at straightforward activity, including passing data between functions, storing references in structs and writing concurrent code, triggers ownership violations that are difficult to reason about. Many developers abandon Rust in this phase.
    2. Phase 2 (Weeks 4 to 12): grudging respect. The borrow checker begins to make sense. The developer understands why the compiler requires what it requires and begins to see the bugs that the compiler is preventing. Code compiles more consistently.
    3. Phase 3 (Months 3 and beyond): appreciation. The developer writes safer code even in other languages. The recognition that compiling Rust code usually works correctly takes hold. The investment in working with the borrow checker pays off in the form of code that does not fail in production.

    Python, by contrast, is widely known for its gentle onboarding. Most developers write working Python within days of starting. The language’s design explicitly targets readability and minimal syntax. “There should be one obvious way to do it” is a core Python principle. For developers new to programming, Python is the natural starting point.

    # Python: Read a file and count word frequencies
    from collections import Counter
    
    with open("text.txt") as f:
        words = f.read().lower().split()
    
    word_counts = Counter(words)
    print(word_counts.most_common(10))
    
    // Rust: Same task — more explicit but equally safe
    use std::collections::HashMap;
    use std::fs;
    
    fn main() {
        let content = fs::read_to_string("text.txt")
            .expect("Failed to read file");
    
        let mut word_counts: HashMap<String, usize> = HashMap::new();
    
        for word in content.split_whitespace() {
            let word = word.to_lowercase();
            *word_counts.entry(word).or_insert(0) += 1;
        }
    
        let mut counts: Vec<(&String, &usize)> = word_counts.iter().collect();
        counts.sort_by(|a, b| b.1.cmp(a.1));
    
        for (word, count) in counts.iter().take(10) {
            println!("{}: {}", word, count);
        }
    }
    

    The output is identical. Python is more concise. Rust is more explicit regarding types and error handling, but at compile time the compiler guarantees that the Rust version will not panic unexpectedly in production (unless the developer requests such behaviour with expect).

    Real-World Use Cases: Where Each Language Predominates

    Where Python Predominates

    Data Science and Machine Learning. No alternative matches Python’s ecosystem. NumPy, Pandas, scikit-learn, PyTorch, TensorFlow, JAX and Hugging Face represent billions of dollars of engineering investment, and they are Python-first. A data scientist who switches to Rust for ML work does not obtain a better ecosystem; they find a substantially smaller one.

    Rapid Prototyping and Research. When the goal is to test an idea quickly, Python’s expressiveness is unmatched. A Python prototype that works in 200 lines might require 600 lines in Rust and additional days of development. For research and experimentation, this matters substantially.

    Scripting and Automation. Python’s standard library includes tools for file manipulation, network requests, regular expressions, parsing JSON, XML and YAML, and most common automation tasks. For DevOps scripts, data processing pipelines and administrative tools, Python’s combination of readability and library richness is difficult to surpass.

    Web Backends for Data-Heavy Applications. When the backend principally serves data from a database and integrates with data science workflows, Python’s FastAPI or Django provides everything needed at reasonable performance. The complete guide to building REST APIs with FastAPI demonstrates how quickly a developer can go from zero to a production-ready API in Python.

    Where Rust Predominates

    Systems Programming. Operating system components, device drivers, embedded systems and firmware, all of which run close to the hardware under strict memory constraints. Rust is rapidly replacing C for new systems code at companies that have experienced C’s memory safety issues.

    High-Performance Network Services. HTTP proxies, DNS resolvers, message queues and game servers, all of which require low latency and high throughput and cannot tolerate garbage collection pauses. The Cloudflare engineering blog has published multiple case studies on replacing CPU-intensive services with Rust implementations and obtaining 10x improvements in efficiency.

    WebAssembly. Rust is the premier language for WebAssembly (WASM), the bytecode format that enables high-performance code to run in web browsers. The Rust-to-WASM toolchain is mature, and Rust WASM modules are used in production by Figma, Shopify and others for compute-intensive browser-side code.

    CLI Tools. Rust’s fast startup time, compared with Python’s 100 to 500 ms import overhead, its static binaries, which require no runtime, and its strong argument parsing libraries make it well suited to command-line tools that must feel instantaneous. Packaging these tools with Docker containers simplifies distribution further, regardless of language. Many widely used developer tools, including ripgrep, fd, bat, exa and delta, are Rust reimplementations of Unix tools that are substantially faster than their predecessors.

    Cryptocurrency and Blockchain. Solana, the high-performance blockchain, is built primarily in Rust. Where smart contract bugs can result in millions of dollars lost instantly, Rust’s safety guarantees become economic necessities rather than engineering preferences.

    Python + Rust: A Combined Approach

    One of the most important developments in the Python ecosystem over the past three years is the maturation of PyO3, a Rust library that makes writing Python extension modules in Rust straightforward. This enables a powerful hybrid architecture: high-level logic, ML pipeline orchestration and user-facing APIs are written in Python, while performance-critical inner loops are implemented in Rust.

    This pattern is already in production at major organisations. Pydantic v2, used by millions of Python developers for data validation, rewrote its core validation engine in Rust via PyO3, achieving 5 to 50 times performance improvements while maintaining a pure Python API. Polars, a DataFrame library competing with Pandas, is built in Rust with a Python interface and consistently outperforms Pandas by 5 to 30 times across most benchmarks. The tokenizers library from Hugging Face, used to prepare text for LLM training, is implemented in Rust, enabling 20-fold speedups in text preprocessing.

    # Using Polars (Rust-backed) instead of Pandas
    import polars as pl
    
    # This reads and processes the CSV using Rust under the hood
    df = (
        pl.read_csv("large_dataset.csv")
        .filter(pl.col("revenue") > 1_000_000)
        .group_by("region")
        .agg(pl.col("revenue").sum().alias("total_revenue"))
        .sort("total_revenue", descending=True)
    )
    
    print(df.head(10))
    # Typically 5-20x faster than equivalent Pandas code
    
    Tip: A choice between Python and Rust is not necessary for most projects. The hybrid approach, Python for orchestration and Rust for performance-critical operations, is increasingly common and well supported. For Python developers encountering performance limits, learning sufficient Rust to write PyO3 extensions is often more valuable than switching languages entirely.

    Career Impact: What These Languages Mean for the Job Market

    Python is among the most in-demand programming languages for job postings in 2026, and its dominance in data science, machine-learning engineering and web development makes the skill valuable in virtually every technology company. Its standing warrants precision rather than slogans, however. In the 2025 Stack Overflow Developer Survey, the most-used language overall was JavaScript (used by roughly 66 percent of respondents), followed by HTML/CSS and SQL, with Python close behind at about 58 percent; Python is therefore among the most-used languages, not the single most-used across all developers. What distinguishes Python in that data is its trajectory and its concentration: it recorded the largest year-on-year increase of any major language (a rise of roughly seven percentage points), and it is the dominant language among data scientists and machine-learning engineers, the segments driving much of the current growth in programming demand. Rust, for its part, was again the most-admired language in the same survey (about 72 percent of those who used it wanted to continue), which is a different measure from raw usage.

    The Rust job market is smaller but growing rapidly and is remarkably well compensated. Rust developers are scarce, since the language’s difficulty creates a supply constraint, and they are disproportionately hired into high-value infrastructure roles, including distributed systems, compilers, operating systems and high-frequency trading infrastructure. Average Rust developer salaries consistently rank among the highest in software engineering compensation surveys.

    The career-optimisation observation is as follows: Python is a floor, Rust is a ceiling. Python provides broad access to the job market. Rust provides access to the highest-complexity, highest-compensation engineering roles that currently exist. For developers who wish to work on the software that runs internet infrastructure, Rust is an increasingly important skill. For developers who wish to work in data science, ML or general software engineering, Python remains the most versatile investment.

    The Decision Framework

    Having examined performance benchmarks, memory models, learning curves and ecosystem comparisons, the decision often reduces to something simpler than any technical metric: what is actually being built?

    If the project involves data pipelines, ML models, web APIs, automation scripts or any application in which correctness and developer velocity matter more than raw performance, Python is almost certainly the appropriate choice. Following clean code principles matters in either language, but Python’s readability makes it a natural fit for maintainable codebases. Its ecosystem, readability and breadth of available libraries make it the most productive choice across a wide range of problems.

    If the project involves infrastructure software, systems tools, high-performance services, embedded applications or any context in which memory safety, predictable performance and runtime efficiency are paramount, Rust merits serious consideration. Its compile-time safety guarantees and zero-overhead abstractions make it the most compelling new systems language in decades.

    For a developer deciding which language to learn first, Python is the recommended starting point. It produces productivity faster, provides access to the richest ecosystem of libraries in any language, and is immediately applicable to data science, web development, automation and most other domains. Adopting Git and GitHub best practices from the start keeps projects organised during learning. When a problem arises in which Python’s performance or safety characteristics become the bottleneck, the developer will then have the context to appreciate what Rust offers and the motivation to invest in its steeper learning curve.

    The concern with memory-corruption bugs that motivated Rust in the mid-2000s produced a language that now runs in the Linux kernel, Android’s Bluetooth stack and Cloudflare’s global network infrastructure. The readability-first hobby project that became Python now underpins much of modern machine learning. Neither outcome was obvious to its originator at the time, and neither language displaced the other. The tools developers build, and the tools they choose to build with, shape the software that shapes the world, which is reason enough to choose them deliberately rather than by allegiance.

    Decision Tree: Python or Rust? Start: New Project Does your project involve ML, data science, scripting, or rapid prototyping? YES Use Python Best ecosystem fit NO Is runtime performance or memory safety critical? YES Use Rust Systems / infra / CLI NO Is developer velocity the top priority? YES Use Python Productivity wins NO Consider Python + Rust PyO3 hybrid approach This is a guide, not a rule—context always matters


    References