DEV Community

楊東霖
楊東霖

Posted on • Originally published at devplaybook.cc

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

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
Enter fullscreen mode Exit fullscreen mode

HTTP Status Codes

The most important ones to use correctly:

Code Meaning When to Use
200 OK Successful GET, PUT, PATCH
201 Created Successful POST that creates a resource
204 No Content Successful DELETE
400 Bad Request Validation error, malformed request
401 Unauthorized Missing or invalid credentials
403 Forbidden Authenticated but not authorized
404 Not Found Resource doesn't exist
409 Conflict Duplicate resource, version conflict
422 Unprocessable Entity Semantically invalid request
500 Internal Server Error Unexpected server-side error

Authentication

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

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Enter fullscreen mode Exit fullscreen mode

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" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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"));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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 });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# Run with auto-reload
uvicorn main:app --reload --port 3000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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))
}
Enter fullscreen mode Exit fullscreen mode

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

Dimension Node.js (Hono) Python (FastAPI) Go (Chi)
Raw throughput High Medium Very High
Memory usage Medium (50-150MB) Medium (50-200MB) Low (5-30MB)
Dev speed Fast Fast Slower
Type safety Optional (TypeScript) Optional (type hints) Built-in
Ecosystem size Largest Large (especially ML) Smaller
Auto-docs Manual (Swagger) Built-in (OpenAPI) Manual
Deployment Node runtime needed Python runtime needed Single binary
Concurrency model Event loop Async/await Goroutines
Cold start Fast Medium Very fast
Learning curve Low Low Medium

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: "alice@example.com" },
  select: { id: true, name: true, role: true },
});
// user is typed: { id: number; name: string; role: Role } | null
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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: "test@example.com", name: "Test User", role: "viewer" });

    expect(res.status).toBe(201);
    expect(res.body.email).toBe("test@example.com");
  });

  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");
  });
});
Enter fullscreen mode Exit fullscreen mode

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.


Level Up Your Dev Workflow

Found this useful? Explore DevPlaybook — cheat sheets, tool comparisons, and hands-on guides for modern developers.

🛒 Get the DevToolkit Starter Kit on Gumroad — 40+ browser-based dev tools, source code + deployment guide included.

Top comments (0)