Express vs FastAPI: Comparing Node.js and Python API Frameworks

Featured image for “Express vs FastAPI: Comparing Node.js and Python API Frameworks”
Express vs FastAPI: Comparing Node.js and Python API Frameworks


March 4, 2026

JavaScript is the standard language for web application frontends, the basis for frameworks like React and Vue. It would therefore make sense to automatically choose a JavaScript framework for the API layer, right?

Not necessarily. An enterprise might use Java Spring Boot to build business logic-heavy microservices. A .NET backend in C# could make a lot of sense if you’re in a Microsoft ecosystem like Azure. Python is a popular choice for dev teams thanks to its extensive tooling, including tools for data science.

So when it comes to building your API layer, which tool actually makes the most sense? There’s no single right answer (and certainly no shortage of options) but for the sake of comparison, we’ll look at two lightweight API frameworks: Express (JavaScript) and FastAPI (Python). We’ll build the same simple Songs API in both frameworks to examine how each handles routing, request validation, error handling, and project setup.

From there, we’ll evaluate differences in developer experience, ecosystem tooling, and runtime characteristics. My goal is to give you the information you need to better determine which framework aligns with your team’s skills, performance needs, and long-term architecture.

Express Implementation

Express.js is one of the most widely used frameworks in the Node.js ecosystem and is commonly used when building scalable APIs and microservices as part of a broader Node.js application architecture. In this example, we’ll create an Express app designed to interface with a database to catalog songs. Songs are defined by a song title (string, required), artist (string, required), and release year (integer, optional).

First, create a directory called songs-expressjs. Open a terminal and make sure this is the working directory. Then, run npm init -y. This will generate a package.json file with default settings.

Inside the directory, create a file named app.js with the following code. For simplicity, the database calls are commented out in favor of hardcoded songs to demonstrate.

const express = require("express");

const app = express();
app.use(express.json());

// GET /songs
app.get(
  "/songs",
  async (req, res) => {
    const { song, artist, year } = req.query;
    // const results = await db.songs.findMany({...});
    const results = [
      { song: "Square Hammer", artist: "Ghost", year: 2016 },
      { song: "Euclid", artist: "Sleep Token", year: 2023 },
      { song: "Pink Pony Club", artist: "Chappell Roan", year: 2023 },
    ];
    res.json(results);
  }
);

// POST /songs
app.post(
  "/songs",
  async (req, res) => {
    const { song, artist, year } = req.body;
    // const existing = await db.songs.findDuplicate(...)
    const duplicateExists = false;
    if (duplicateExists) {
      const err = new Error("Song already exists");
      err.status = 409;
      throw err;
    }
    // const created = await db.songs.insert(...)
    const created = {
      id: 1,
      song,
      artist,
      year: year ?? null,
      createdAt: new Date().toISOString(),
    };
    res.status(201).json({ data: created });
  }
);
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Listening on port ${port}`));

Run npm install express from the songs-express directory. This will generate a package-lock.json file and a node_modules folder in the same directory.

Next, run node app.js to start the app. This will tell you what port the app is running on.

Using Postman or another means of making API calls, make a GET request to localhost:/songs. This should return:

[
    {
        "song": "Square Hammer",
        "artist": "Ghost",
        "year": 2016
    },
    {
        "song": "Euclid",
        "artist": "Sleep Token",
        "year": 2023
    },
    {
        "song": "Pink Pony Club",
        "artist": "Chappell Roan",
        "year": 2023
    }
]

Make a POST request to the same /songs endpoint with this body:

{
    "song": "Blinding Lights",
    "artist": "The Weeknd",
    "year": 2019
}

The response should look like the following (with a different timestamp):

{
    "data": {
        "id": 1,
        "song": "Blinding Lights",
        "artist": "The Weeknd",
        "year": 2019,
        "createdAt": "2026-02-24T08:44:40.469Z"
    }
}

FastAPI Implementation

Now that we’ve built the Songs API using Express, we’ll recreate the same application in FastAPI. This implementation follows the same functionality and endpoints but uses Python and FastAPI’s built-in features for request validation, typing, and error handling.

To begin, create a directory called songs-fastapi. Open a terminal and make sure this is the working directory.

Inside the directory, create a file named main.py with the following code. Note the differences from the Express app, with a particular look at the # Models and # Error Handling sections:

from __future__ import annotations

import os
from datetime import datetime, timezone
from typing import List, Optional

from fastapi import FastAPI, Query, Body, Request
from fastapi.responses import JSONResponse

from pydantic import BaseModel, Field

# from sqlalchemy import select
# ^ this is what is referenced at the top of the create_song() function in the POST endpoint

app = FastAPI(title="Songs API")

# Models - These use a library called Pydantic to define types in request and response objects
class SongOut(BaseModel):
    song: str
    artist: str
    year: int
class SongCreate(BaseModel):
    song: str = Field(..., min_length=1)
    artist: str = Field(..., min_length=1)
    year: Optional[int] = None
class SongCreated(BaseModel):
    id: int
    song: str
    artist: str
    year: Optional[int] = None
    createdAt: str
class CreateSongResponse(BaseModel):
    data: SongCreated

# GET /songs
@app.get("/songs", response_model=List[SongOut])
async def list_songs(
    song: Optional[str] = Query(default=None),
    artist: Optional[str] = Query(default=None),
    year: Optional[int] = Query(default=None),
):
    _ = (song, artist, year)
    """" # This would be the actual DB call
    filters = {}
    if song is not None:
        filters["song"] = song
    if artist is not None:
        filters["artist"] = artist
    if year is not None:
        filters["year"] = year
    results = await db.songs.find_many(where=filters)
    """
    results = [
        {"song": "Square Hammer", "artist": "Ghost", "year": 2016},
        {"song": "Euclid", "artist": "Sleep Token", "year": 2023},
        {"song": "Pink Pony Club", "artist": "Chappell Roan", "year": 2023},
    ]
    return results

# POST /songs
@app.post("/songs", status_code=201, response_model=CreateSongResponse)
async def create_song(payload: SongCreate = Body(...)):
    """ # This would be the actual DB call
    result = await session.execute(
        select(Song).where(
            Song.song == payload.song,
            Song.artist == payload.artist,
        )
    )
    existing = result.scalar_one_or_none()
    """
    duplicate_exists = False
    if duplicate_exists:
        raise DuplicateSongError("Song already exists")
    """ # This would be the actual DB call
    new_song = Song(
        song=payload.song,
        artist=payload.artist,
        year=payload.year,
    )
    session.add(new_song)
    await session.commit()
    await session.refresh(new_song)
    created = new_song
    """
    created = {
        "id": 1,
        "song": payload.song,
        "artist": payload.artist,
        "year": payload.year,
        "createdAt": datetime.now(timezone.utc).isoformat(),
    }
    return {"data": created}

# Error Handling - We route custom-defined errors through exception handling middleware
class AppError(Exception):
    def __init__(self, message: str, status: int = 500, details: dict | None = None, name: str = "Error"):
        super().__init__(message)
        self.status = status
        self.details = details
        self.name = name

class DuplicateSongError(AppError):
    def __init__(self, message: str = "Song already exists", details: dict | None = None):
        super().__init__(message=message, status=409, details=details, name="ConflictError")

@app.exception_handler(AppError)
async def app_error_handler(_request: Request, err: AppError):
    payload = {
        "error": err.name or "Error",
        "message": str(err) or "Internal Server Error",
    }
    if err.details:
        payload["details"] = err.details
    if err.status >= 500:
        # Mirror Express behavior: log server errors
        print(err)
    return JSONResponse(status_code=err.status, content=payload)

@app.exception_handler(404)
async def not_found_handler(_request: Request, _exc):
    # Mirror your Express 404 middleware payload shape
    return JSONResponse(
        status_code=404,
        content={"error": "Error", "message": "Not found"},
    )

@app.exception_handler(Exception)
async def unhandled_exception_handler(_request: Request, err: Exception):
    # Catch-all like Express error middleware
    print(err)
    return JSONResponse(
        status_code=500,
        content={"error": err.__class__.__name__ or "Error", "message": str(err) or "Internal Server Error"},
    )

# Start App
if __name__ == "__main__":
    import uvicorn
    port = int(os.getenv("PORT", "3000"))
    uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)

Then, run python -m venv venv to set up your virtual environment. Next, run source venv/bin/activate to activate the virtual environment.

Create a file named requirements.txt in the current directory with these contents:

fastapi==0.110.0
uvicorn[standard]==0.29.0
pydantic==2.6.4
asyncpg==0.29.0

Run pip3 install -r requirements.txt to install requirements. Then, run uvicorn main:app --reload to start the app. This will tell you what port the app is running on. And finally, repeat the GET and POST steps from the Express Implementation.

Express vs FastAPI: Key Differences

Now that we’ve built the same Songs API in both Express and FastAPI, we can step back and compare the frameworks beyond syntax. The differences aren’t just about language preference; they show up in tooling ecosystems, dependency management, runtime behavior, and developer experience. Let’s look at a few areas that often influence architectural decisions.

NPM vs. PyPI

Let’s compare the tooling repositories used by Express vs FastAPI.

For Express, we’re using NPM (Node Package Manager). NPM is the largest software registry in the world. For FastAPI, we’re using PyPI (Python Package Index), which is the official Python third-party software repository.

Projects built in Express can be easily set up locally with specific package versions. On the other hand, because it’s best practice for FastAPI apps to have dependencies configured in a virtual environment based on a requirements.txt file, additional manual setup is required. Python’s ecosystem has also continued to evolve with modern Python package management tools that simplify dependency management and environment setup.

Community size is an important highlight for this comparison. Being the largest collection of its kind, NPM has a wide breadth of offerings for different needs, both frontend and backend. At the same time, PyPI can often offer deeper tooling, specifically for backend purposes, such as data science and machine learning (which is where Python excels).

Runtime Performance

A key consideration when comparing these frameworks is how they handle requests.

Traditionally, Python frameworks were synchronous, using the WSGI server runtime. Inside the Node.js runtime environment, Express apps use asynchronous, non-blocking I/O capabilities. Multiple operations can be started and allowed to run simultaneously, instead of handling one operation at a time.

Python frameworks have since evolved to support asynchronous operations with the ASGI server. FastAPI performs well for high-concurrency and intensive data operations. Node’s event-driven runtime makes its frameworks (like Express) suitable for real-time operations such as chats or simple, static content.

Conclusion

Choosing an API framework depends on both your application’s needs and your development team’s strengths. In this article, we explored Express vs FastAPI by building the same API in both frameworks and examining differences in tooling, ecosystem support, and runtime behavior.

Along the way, we looked at:

  • The process of setting up and implementing a basic API in both frameworks
  • Differences in ecosystem tooling, including NPM for Node.js and PyPI for Python
  • Runtime and architectural considerations that influence performance and scalability

While both frameworks are capable of powering modern APIs, they reflect the strengths of their respective ecosystems. Express benefits from the maturity and breadth of the Node.js ecosystem, while FastAPI leverages Python’s strong typing support and powerful backend tooling. Organizations building APIs in Python often rely on experienced teams specializing in Python API and backend services to design scalable and maintainable systems.

Ultimately, the right choice depends on factors such as your team’s familiarity with the language, the surrounding technology stack, and the types of workloads your application needs to support. By understanding the tradeoffs between frameworks like Express and FastAPI, teams can make more informed decisions when designing their API layer.


About The Author

More From Chris Brown


Discuss This Article

Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments