← All Articles · · 14 min read

How to Build a REST API in 2025: Node.js vs Python vs Go

A practical guide to building REST APIs with Node.js (Express/Hono), Python (FastAPI), and Go (net/http + Chi). Includes code examples, performance considerations, and when to choose each language.

rest-apinodejspythongobackendweb-development

Building a REST API in 2025 means making more choices than ever—not just about your framework, but your language runtime, validation library, ORM, deployment target, and more. The good news: Node.js, Python, and Go have all matured into excellent choices for API development. The real question is which fits your context.

This guide walks through a practical implementation of the same API in all three languages, compares them honestly, and helps you decide.


REST API Fundamentals

Before diving into implementations, let’s align on what a production-quality REST API needs:

Routes and Resource Design

REST organizes APIs around resources, not actions:

# Resources (correct)
GET    /users           - list users
GET    /users/:id       - get one user
POST   /users           - create user
PUT    /users/:id       - replace user
PATCH  /users/:id       - partial update
DELETE /users/:id       - delete user

# Actions (avoid)
POST /getUser
POST /createUser
GET  /deleteUser?id=123

HTTP Status Codes

The most important ones to use correctly:

CodeMeaningWhen to Use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST that creates a resource
204No ContentSuccessful DELETE
400Bad RequestValidation error, malformed request
401UnauthorizedMissing or invalid credentials
403ForbiddenAuthenticated but not authorized
404Not FoundResource doesn’t exist
409ConflictDuplicate resource, version conflict
422Unprocessable EntitySemantically invalid request
500Internal Server ErrorUnexpected server-side error

Authentication

JWT Bearer tokens are the standard for stateless APIs in 2025:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Error Response Format

Be consistent. A standard error shape makes client-side handling predictable:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "details": [
      { "field": "email", "message": "Must be a valid email address" }
    ]
  }
}

Node.js Implementation

Why Node.js?

  • JavaScript everywhere: use the same language on frontend and backend
  • Massive ecosystem: npm has a package for everything
  • Non-blocking I/O: excellent for APIs that are primarily I/O bound (database calls, external API calls)
  • Great developer experience: fast iteration, rich tooling
  • Hono: a modern, lightweight alternative to Express with TypeScript-first design

Express — The Established Standard

npm install express cors helmet express-validator jsonwebtoken
npm install -D typescript @types/express ts-node-dev
// src/app.ts
import express, { Request, Response, NextFunction } from "express";
import cors from "cors";
import helmet from "helmet";
import { body, validationResult } from "express-validator";
import jwt from "jsonwebtoken";

const app = express();

// Middleware
app.use(express.json());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(",") }));
app.use(helmet());

// Auth middleware
const authenticate = (req: Request, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (!token) return res.status(401).json({ error: { code: "UNAUTHORIZED" } });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!);
    (req as any).user = payload;
    next();
  } catch {
    res.status(401).json({ error: { code: "INVALID_TOKEN" } });
  }
};

// Routes
app.get("/users", authenticate, async (req, res) => {
  try {
    const page = parseInt(req.query.page as string) || 1;
    const limit = parseInt(req.query.limit as string) || 20;

    const users = await db.user.findMany({
      skip: (page - 1) * limit,
      take: limit,
    });

    const total = await db.user.count();

    res.json({
      data: users,
      pagination: { page, limit, total },
    });
  } catch (err) {
    res.status(500).json({ error: { code: "INTERNAL_ERROR" } });
  }
});

app.post(
  "/users",
  authenticate,
  [
    body("email").isEmail().normalizeEmail(),
    body("name").trim().isLength({ min: 2, max: 100 }),
    body("role").isIn(["admin", "editor", "viewer"]),
  ],
  async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({
        error: {
          code: "VALIDATION_ERROR",
          details: errors.array(),
        },
      });
    }

    try {
      const user = await db.user.create({ data: req.body });
      res.status(201).json(user);
    } catch (err: any) {
      if (err.code === "P2002") {
        return res.status(409).json({
          error: { code: "DUPLICATE_EMAIL", message: "Email already exists" },
        });
      }
      res.status(500).json({ error: { code: "INTERNAL_ERROR" } });
    }
  }
);

app.listen(3000, () => console.log("API running on port 3000"));

Hono — The Modern Alternative

Hono is gaining significant traction in 2025 for new projects. It’s faster than Express, TypeScript-first, and runs on Node, Deno, Bun, and Cloudflare Workers without changes.

npm install hono @hono/node-server zod @hono/zod-validator
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";

const app = new Hono();

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  role: z.enum(["admin", "editor", "viewer"]),
});

// Middleware
app.use("*", async (c, next) => {
  const token = c.req.header("Authorization")?.replace("Bearer ", "");
  if (!token) return c.json({ error: { code: "UNAUTHORIZED" } }, 401);
  // verify token...
  await next();
});

app.post("/users", zValidator("json", createUserSchema), async (c) => {
  const body = c.req.valid("json"); // fully typed, validated
  const user = await db.user.create({ data: body });
  return c.json(user, 201);
});

serve({ fetch: app.fetch, port: 3000 });

Node.js pros: Massive ecosystem, JavaScript team synergy, huge community, great for rapid iteration. Node.js cons: Single-threaded (CPU-bound work needs worker threads), can be verbose for complex validation, package ecosystem quality varies.


Python Implementation (FastAPI)

Why FastAPI?

FastAPI has become the dominant Python API framework for new projects, and for good reason: it’s fast (async by default), generates OpenAPI documentation automatically, uses type annotations for validation, and catches errors at startup rather than runtime.

pip install fastapi uvicorn[standard] python-jose[cryptography] pydantic sqlalchemy asyncpg
# main.py
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr, field_validator
from jose import JWTError, jwt
from typing import Optional, Literal
from datetime import datetime
import os

app = FastAPI(title="Users API", version="1.0.0")
security = HTTPBearer()

# --- Models ---

class CreateUserRequest(BaseModel):
    email: EmailStr
    name: str
    role: Literal["admin", "editor", "viewer"]

    @field_validator("name")
    @classmethod
    def name_length(cls, v: str) -> str:
        if len(v.strip()) < 2:
            raise ValueError("Name must be at least 2 characters")
        return v.strip()

class UserResponse(BaseModel):
    id: int
    email: str
    name: str
    role: str
    created_at: datetime

    class Config:
        from_attributes = True  # Enables ORM mode

class PaginatedUsers(BaseModel):
    data: list[UserResponse]
    pagination: dict

# --- Auth ---

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security),
):
    try:
        payload = jwt.decode(
            credentials.credentials,
            os.environ["JWT_SECRET"],
            algorithms=["HS256"],
        )
        return payload
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail={"code": "INVALID_TOKEN"},
        )

# --- Routes ---

@app.get("/users", response_model=PaginatedUsers)
async def list_users(
    page: int = 1,
    limit: int = 20,
    current_user=Depends(get_current_user),
):
    """List all users with pagination."""
    offset = (page - 1) * limit
    async with AsyncSession(engine) as session:
        users = await session.execute(
            select(User).offset(offset).limit(limit)
        )
        total = await session.scalar(select(func.count(User.id)))

    return {
        "data": users.scalars().all(),
        "pagination": {"page": page, "limit": limit, "total": total},
    }

@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(
    body: CreateUserRequest,
    current_user=Depends(get_current_user),
):
    """Create a new user."""
    async with AsyncSession(engine) as session:
        existing = await session.scalar(
            select(User).where(User.email == body.email)
        )
        if existing:
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail={"code": "DUPLICATE_EMAIL"},
            )

        user = User(**body.model_dump())
        session.add(user)
        await session.commit()
        await session.refresh(user)
        return user

@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, current_user=Depends(get_current_user)):
    async with AsyncSession(engine) as session:
        user = await session.get(User, user_id)
        if not user:
            raise HTTPException(status_code=404, detail={"code": "NOT_FOUND"})
        return user
# Run with auto-reload
uvicorn main:app --reload --port 3000

Automatic OpenAPI docs at: http://localhost:3000/docs

FastAPI’s killer feature: the OpenAPI documentation at /docs is generated automatically from your type annotations and docstrings. No separate Swagger setup needed. Your API is self-documenting from day one.

Python/FastAPI pros: Excellent for ML/data-heavy APIs (numpy, pandas, torch are all Python), automatic OpenAPI docs, Pydantic validation is comprehensive, async from the start, readable code. Python/FastAPI cons: Slower raw throughput than Go, Python’s GIL limits CPU parallelism, deployment can be heavier than Go binaries.


Go Implementation (net/http + Chi)

Why Go?

Go is the choice when performance, low memory footprint, and operational simplicity matter most. Go binaries are statically compiled—deploy a single file, no runtime dependencies. Memory usage is typically 10-20x lower than equivalent Node.js apps. And Go’s concurrency model (goroutines) handles thousands of concurrent connections elegantly.

go mod init api-example
go get github.com/go-chi/chi/v5
go get github.com/golang-jwt/jwt/v5
go get github.com/go-playground/validator/v10
// main.go
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/golang-jwt/jwt/v5"
    "github.com/go-playground/validator/v10"
)

var validate = validator.New()

// --- Models ---

type CreateUserRequest struct {
    Email string `json:"email" validate:"required,email"`
    Name  string `json:"name"  validate:"required,min=2,max=100"`
    Role  string `json:"role"  validate:"required,oneof=admin editor viewer"`
}

type User struct {
    ID    int    `json:"id"`
    Email string `json:"email"`
    Name  string `json:"name"`
    Role  string `json:"role"`
}

type ApiError struct {
    Code    string `json:"code"`
    Message string `json:"message,omitempty"`
}

// --- Helpers ---

func respondJSON(w http.ResponseWriter, status int, payload any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(payload)
}

func respondError(w http.ResponseWriter, status int, code string, msg string) {
    respondJSON(w, status, map[string]any{
        "error": ApiError{Code: code, Message: msg},
    })
}

// --- Auth Middleware ---

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tokenStr := r.Header.Get("Authorization")
        if len(tokenStr) < 8 || tokenStr[:7] != "Bearer " {
            respondError(w, 401, "UNAUTHORIZED", "")
            return
        }
        tokenStr = tokenStr[7:]

        token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
            return []byte(jwtSecret), nil
        })
        if err != nil || !token.Valid {
            respondError(w, 401, "INVALID_TOKEN", "")
            return
        }

        next.ServeHTTP(w, r)
    })
}

// --- Handlers ---

func listUsers(w http.ResponseWriter, r *http.Request) {
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    if page < 1 {
        page = 1
    }
    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
    if limit < 1 || limit > 100 {
        limit = 20
    }

    users, total, err := userRepo.List(r.Context(), page, limit)
    if err != nil {
        respondError(w, 500, "INTERNAL_ERROR", "")
        return
    }

    respondJSON(w, 200, map[string]any{
        "data": users,
        "pagination": map[string]int{
            "page": page, "limit": limit, "total": total,
        },
    })
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, 400, "INVALID_JSON", err.Error())
        return
    }

    if err := validate.Struct(req); err != nil {
        respondError(w, 400, "VALIDATION_ERROR", err.Error())
        return
    }

    user, err := userRepo.Create(r.Context(), req)
    if err != nil {
        if isDuplicateEmail(err) {
            respondError(w, 409, "DUPLICATE_EMAIL", "Email already exists")
            return
        }
        respondError(w, 500, "INTERNAL_ERROR", "")
        return
    }

    respondJSON(w, 201, user)
}

// --- Main ---

func main() {
    r := chi.NewRouter()

    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.RealIP)

    r.Group(func(r chi.Router) {
        r.Use(AuthMiddleware)
        r.Get("/users", listUsers)
        r.Post("/users", createUser)
        r.Get("/users/{userID}", getUser)
        r.Patch("/users/{userID}", updateUser)
        r.Delete("/users/{userID}", deleteUser)
    })

    log.Println("API running on :3000")
    log.Fatal(http.ListenAndServe(":3000", r))
}

Go pros: Exceptional performance, tiny memory footprint (5-30MB vs 100-300MB for Node), single binary deployment, built-in concurrency, excellent for microservices. Go cons: More verbose than Python/Node, smaller ecosystem, steeper learning curve, slower initial development speed.


Feature Comparison

DimensionNode.js (Hono)Python (FastAPI)Go (Chi)
Raw throughputHighMediumVery High
Memory usageMedium (50-150MB)Medium (50-200MB)Low (5-30MB)
Dev speedFastFastSlower
Type safetyOptional (TypeScript)Optional (type hints)Built-in
Ecosystem sizeLargestLarge (especially ML)Smaller
Auto-docsManual (Swagger)Built-in (OpenAPI)Manual
DeploymentNode runtime neededPython runtime neededSingle binary
Concurrency modelEvent loopAsync/awaitGoroutines
Cold startFastMediumVery fast
Learning curveLowLowMedium

Database Integration Tips

Node.js — Prisma

Prisma is the TypeScript ORM of choice in 2025. Fully typed query results, schema-first, and excellent migration tooling.

// schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  role      Role     @default(viewer)
  createdAt DateTime @default(now())
}

enum Role { admin editor viewer }

// Usage — fully typed
const user = await prisma.user.findFirst({
  where: { email: "[email protected]" },
  select: { id: true, name: true, role: true },
});
// user is typed: { id: number; name: string; role: Role } | null

Python — SQLAlchemy 2.0 Async

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(unique=True)
    name: Mapped[str]
    role: Mapped[str]

engine = create_async_engine("postgresql+asyncpg://localhost/db")

Go — pgx (Direct PostgreSQL Driver)

For Go, skip the ORM and use pgx directly for best performance:

import "github.com/jackc/pgx/v5/pgxpool"

pool, _ := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))

var user User
err := pool.QueryRow(ctx,
    "SELECT id, email, name, role FROM users WHERE id = $1",
    userID,
).Scan(&user.ID, &user.Email, &user.Name, &user.Role)

When to Choose Each Language

Choose Node.js/TypeScript when:

  • Your team already writes TypeScript on the frontend
  • You’re building a BFF (Backend for Frontend) that mostly proxies other services
  • You need the widest possible library ecosystem
  • Developer velocity and iteration speed are the primary constraints
  • You’re running on serverless (Lambda, Cloudflare Workers, Vercel Edge)

Choose Python/FastAPI when:

  • Your API serves ML models or does data processing
  • You need automatic API documentation for internal or external consumers
  • Your team knows Python well and the API isn’t performance-critical
  • You’re integrating with data science tooling (pandas, numpy, scipy)
  • You need fast prototyping with good type safety

Choose Go when:

  • High throughput and low latency are requirements (>10k req/s)
  • You’re building microservices where memory efficiency matters at scale
  • Simple deployment (single binary, Docker scratch image) is a priority
  • The service needs to handle lots of long-lived connections (websockets, streaming)
  • You want a compiled language with strong concurrency support

Testing Your API

Regardless of language, test your API’s behavior, not its implementation. Use devplaybook.cc/tools/api-tester for manual testing during development, and write integration tests that test the full HTTP stack.

// Node.js — Vitest + supertest
describe("POST /users", () => {
  it("returns 201 with valid input", async () => {
    const res = await request(app)
      .post("/users")
      .set("Authorization", `Bearer ${testToken}`)
      .send({ email: "[email protected]", name: "Test User", role: "viewer" });

    expect(res.status).toBe(201);
    expect(res.body.email).toBe("[email protected]");
  });

  it("returns 400 with invalid email", async () => {
    const res = await request(app)
      .post("/users")
      .set("Authorization", `Bearer ${testToken}`)
      .send({ email: "not-an-email", name: "Test", role: "viewer" });

    expect(res.status).toBe(400);
    expect(res.body.error.code).toBe("VALIDATION_ERROR");
  });
});

Final Recommendations

In 2025, all three are production-ready. The meta-advice:

  1. Match your team’s existing skills — the best language is the one your team writes well. A great Node.js API beats a mediocre Go API every time.
  2. Don’t over-optimize early — unless you’re expecting significant scale from day one, developer velocity matters more than raw throughput.
  3. FastAPI for anything touching ML — the Python ecosystem for data and ML has no equivalent in Node or Go.
  4. Go for microservices at scale — when you’re running 50 instances of a service and memory costs matter, Go’s efficiency adds up.
  5. Hono/Node for full-stack JS teams — type sharing between frontend and backend via shared packages is a real productivity advantage.

Start with what your team knows, validate your API design with real usage, and optimize based on measured bottlenecks rather than theoretical ones.


Need to test your API while building? devplaybook.cc/tools/api-tester lets you send HTTP requests directly from the browser with custom headers, auth tokens, and JSON bodies—no install required.

Free Newsletter

Level Up Your Dev Workflow

Get new tools, guides, and productivity tips delivered to your inbox.

Plus: grab the free Developer Productivity Checklist when you subscribe.

Found this guide useful? Check out our free developer tools.