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.
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:
| 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...
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
| 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
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: "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");
});
});
Final Recommendations
In 2025, all three are production-ready. The meta-advice:
- 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.
- Don’t over-optimize early — unless you’re expecting significant scale from day one, developer velocity matters more than raw throughput.
- FastAPI for anything touching ML — the Python ecosystem for data and ML has no equivalent in Node or Go.
- Go for microservices at scale — when you’re running 50 instances of a service and memory costs matter, Go’s efficiency adds up.
- 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.
Affiliate disclosure: Some links below are affiliate links — we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.