After learning Go, I wanted to try the other backend language I keep running into: Python. And in Python land, the framework everyone seems to recommend for APIs right now is FastAPI.
Good news for us frontend folks: FastAPI feels surprisingly familiar. If you've used Express for routes and Zod for validation, you already understand most of it. You just need to learn a bit of Python first.
So this post has two parts:
- Python for JS/TS developers: the syntax you need, side by side.
- FastAPI for Express developers: building a small todo API, compared with what you'd write in Node.
Let's go 🐍
Why would a frontend dev learn Python and FastAPI?#
- AI and data live in Python. Most AI, machine learning, and data libraries are Python first. If your app needs any of that, a small Python API is often the easiest bridge.
- FastAPI is fast to write. You describe your data with type hints, and you get validation, error messages, and API docs for free.
- Free OpenAPI docs. Every FastAPI app gets an interactive docs page at
/docs. You can even generate TypeScript types for your frontend from it. - Python is easy to read. It was designed to look almost like plain English.
The big picture#
| Topic | JavaScript / TypeScript | Python |
|---|---|---|
| Types | Dynamic (JS) / static, erased at runtime (TS) | Dynamic, with optional type hints |
| How it runs | Node, Bun, or the browser | The Python interpreter |
| Package manager | npm / pnpm / yarn | pip, or the newer uv |
| Formatter / linter | Prettier, Biome, ESLint | Ruff (does both, and it's very fast) |
| Code blocks | Curly braces { } |
Indentation |
| Semicolons | Optional | Not used |
| "Nothing" value | null and undefined |
Only None |
| Booleans | true / false |
True / False |
| Async | async/await everywhere |
async/await exists, but sync code is normal |
| Web framework | Express, Hono, Fastify | FastAPI, Flask, Django |
| Validation | Zod, Valibot | Pydantic (built into FastAPI) |
Part 1: Python for JS/TS developers#
1. Setting up a project#
In the JS world, you'd run pnpm init and pnpm add express. In Python, I recommend uv. It feels just like pnpm: fast, one tool, and a lockfile.
# install uv once (macOS / Linux)
curl -LsSf https://astral.sh/uv/install.sh | sh
uv init todo-api
cd todo-api
uv add "fastapi[standard]"| npm world | Python world (with uv) |
|---|---|
package.json |
pyproject.toml |
pnpm-lock.yaml |
uv.lock |
pnpm init |
uv init |
pnpm add express |
uv add fastapi |
pnpm add -D vitest |
uv add --dev pytest |
node_modules/ |
.venv/ (a "virtual environment") |
pnpm exec tsx index.ts |
uv run main.py |
What's a virtual environment? It's Python's version of node_modules: a folder that holds the packages for this project only. uv creates .venv/ for you and uses it automatically when you run uv run. Add it to .gitignore, same as node_modules.
2. Variables#
JavaScript
let name = "Fachalik";
const age = 25;TypeScript
let name: string = "Fachalik";
const age: number = 25;Python
name = "Fachalik"
age: int = 25 # type hint is optional
MAX_TODOS = 100 # "constant" by convention onlyWhat's different
- No
let,const, orvar. You just write the name and assign it. - There's no real
const. UsingUPPER_CASEnames is the convention that says "please don't change this". - Type hints look just like TypeScript (
name: str), but Python itself doesn't check them when your code runs. Your editor and tools like Pyright or mypy do. Remember this, because FastAPI is the exception (more on that later). - Python uses
snake_casefor variables and functions, notcamelCase:user_name, notuserName.
3. Indentation instead of curly braces#
This is the first thing that will feel weird. In Python, indentation is the syntax. There are no { } around blocks.
JavaScript
if (age >= 18) {
console.log("Adult");
} else if (age >= 13) {
console.log("Teen");
} else {
console.log("Kid");
}Python
if age >= 18:
print("Adult")
elif age >= 13:
print("Teen")
else:
print("Kid")- A line that starts a block ends with a colon
:. - Everything indented under it (4 spaces is the standard) is inside the block.
else ifis spelledelif.console.log≈print.
Let your editor and Ruff handle the indentation, and you'll stop noticing it after a day.
4. Types you already know#
| TypeScript | Python |
|---|---|
string |
str |
number |
int (whole numbers) and float (decimals) |
boolean |
bool (True / False) |
null / undefined |
None |
string[] |
list[str] |
Record<string, number> |
dict[str, int] |
[string, number] (tuple) |
tuple[str, int] |
string | null |
str | None |
any |
Any (from typing) |
Strings: template literals become f-strings. Put an f before the quote:
const greeting = `Hello, ${name}! You are ${age} years old.`;greeting = f"Hello, {name}! You are {age} years old."Logic operators are words, not symbols:
| JS / TS | Python |
|---|---|
&& |
and |
|| |
or |
! |
not |
=== |
== |
!== |
!= |
x === null |
x is None |
Python's == doesn't do weird type coercion like JS's ==, so there's no need for ===. 🎉
5. Arrays and objects → lists and dicts#
JavaScript
const fruits = ["apple", "banana"];
fruits.push("cherry");
console.log(fruits.length);
const user = { name: "Fachalik", age: 25 };
console.log(user.name);Python
fruits = ["apple", "banana"]
fruits.append("cherry")
print(len(fruits))
user = {"name": "Fachalik", "age": 25}
print(user["name"])
print(user.get("email")) # None instead of a KeyError if missingpush→append,.length→len(...).- A
dictis like a JS object, but keys must be quoted, and you read them withuser["name"], notuser.name. user["email"]on a missing key throws an error. Useuser.get("email")when the key might not exist. It's likeuser?.email.
6. .map() and .filter() → comprehensions#
Python has map() and filter(), but most people write list comprehensions instead. They read like a sentence.
JavaScript
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map((n) => n * 2);
const evens = numbers.filter((n) => n % 2 === 0);Python
numbers = [1, 2, 3, 4, 5]
doubled = [n * 2 for n in numbers]
evens = [n for n in numbers if n % 2 == 0]Read it as: "give me n * 2 for each n in numbers". You can do the same with dicts:
prices = {"apple": 1.0, "banana": 0.5}
discounted = {name: price * 0.9 for name, price in prices.items()}7. Loops#
JavaScript
for (const fruit of fruits) {
console.log(fruit);
}
fruits.forEach((fruit, i) => console.log(i, fruit));
for (let i = 0; i < 5; i++) {
console.log(i);
}Python
for fruit in fruits:
print(fruit)
for i, fruit in enumerate(fruits):
print(i, fruit)
for i in range(5): # 0, 1, 2, 3, 4
print(i)Python's for ... in works like JS's for ... of. Careful: in JS, for ... in loops over keys, which is a common mix-up.
8. Functions#
TypeScript
function greet(name: string, greeting = "Hello"): string {
return `${greeting}, ${name}!`;
}
const add = (a: number, b: number) => a + b;
greet("Fachalik");
greet("Fachalik", "Hi");Python
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
add = lambda a, b: a + b # rarely used, prefer def
greet("Fachalik")
greet("Fachalik", greeting="Hi") # keyword argumentfunction→def, and the return type goes after->.- Keyword arguments are a superpower. You can pass any argument by name:
greeting="Hi". It's like passing an options object in JS, but built into the language. lambdais Python's arrow function, but it can only hold one expression. Most of the time you'll just usedef.
9. Classes#
Python has classes, and they look a lot like JS classes:
TypeScript
class User {
constructor(
public name: string,
public age: number,
) {}
greet() {
return `Hi, I'm ${this.name}`;
}
}Python
class User:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def greet(self) -> str:
return f"Hi, I'm {self.name}"
user = User("Fachalik", 25) # no `new` keywordconstructor→__init__.this→self, and you must writeselfas the first parameter of every method.- No
newkeyword. You call the class like a function.
In FastAPI you won't write many classes like this. You'll mostly write Pydantic models, which are classes that describe data. We'll see them in Part 2.
10. Error handling#
JavaScript
try {
const data = JSON.parse(text);
} catch (err) {
console.error(err);
} finally {
console.log("done");
}Python
import json
try:
data = json.loads(text)
except json.JSONDecodeError as err:
print(err)
finally:
print("done")Same idea: try / except / finally. throw is spelled raise. One nice difference: you can catch specific error types, so you don't accidentally swallow bugs you didn't expect.
11. Imports#
import { readFile } from "node:fs/promises";
import express from "express";
import { formatDate } from "./utils";from pathlib import Path
from fastapi import FastAPI
from utils import format_date # utils.py in the same projectThere's no export keyword. Everything at the top level of a file can be imported. Names starting with _ are "private" by convention.
Python gotchas for JS developers#
A few things that tripped me up:
- Empty lists and dicts are falsy. In JS,
[]and{}are truthy. In Python,if []:isFalse. Handy, but surprising at first. - Don't use a list or dict as a default argument.
def add(item, items=[])shares the same list between every call. Useitems: list | None = Noneand create the list inside. - Integer division:
7 / 2is3.5, and7 // 2is3. - No
++. Usecount += 1.
Part 2: FastAPI for Express developers#
Now the fun part. Let's build a small todo API and compare it with Express.
12. Hello, API#
Express (TypeScript)
import express from "express";
const app = express();
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "Hello from Express!" });
});
app.listen(3000);FastAPI (Python)
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def home():
return {"message": "Hello from FastAPI!"}Run it:
uv run fastapi dev main.pyOpen http://localhost:8000 and you'll see your JSON. fastapi dev reloads on save, just like nodemon or tsx watch.
What's different
@app.get("/")is a decorator. Think of it as "register this function as the handler forGET /".- You don't call
res.json(). You just return a dict, and FastAPI turns it into JSON. - No
express.json()needed. JSON bodies are parsed for you.
13. Free API docs 🎁#
Now open http://localhost:8000/docs.
You get an interactive Swagger UI page for your API, generated from your code. You can send real requests from the browser. In Express, you'd need extra packages and hand-written specs for this. In FastAPI, it's just there.
The raw OpenAPI spec lives at /openapi.json. Keep that in mind, we'll use it later to generate TypeScript types.
14. Path and query parameters#
Express
// GET /todos/42
app.get("/todos/:id", (req, res) => {
const id = Number(req.params.id); // it's a string, convert it yourself
res.json({ id });
});
// GET /todos?done=true&limit=5
app.get("/todos", (req, res) => {
const done = req.query.done === "true";
const limit = Number(req.query.limit ?? 10);
res.json({ done, limit });
});FastAPI
@app.get("/todos/{todo_id}")
def get_todo(todo_id: int):
return {"id": todo_id}
@app.get("/todos")
def list_todos(done: bool | None = None, limit: int = 10):
return {"done": done, "limit": limit}This is where FastAPI clicked for me. 🤯
{todo_id}in the path matches thetodo_idparameter.- Any parameter that's not in the path becomes a query parameter.
- The type hints are used at runtime.
todo_id: intmeans FastAPI converts"42"to42for you. If someone calls/todos/abc, they get a clear 422 error automatically. You wrote zero validation code.
Remember when I said Python doesn't check type hints? FastAPI is the exception. It reads them and uses them to parse and validate requests.
15. Request bodies: Zod → Pydantic#
Express + Zod
import { z } from "zod";
const TodoCreate = z.object({
title: z.string().min(1).max(100),
done: z.boolean().default(false),
});
app.post("/todos", (req, res) => {
const result = TodoCreate.safeParse(req.body);
if (!result.success) {
return res.status(422).json(result.error);
}
const todo = result.data;
res.status(201).json(todo);
});FastAPI + Pydantic
from pydantic import BaseModel, Field
class TodoCreate(BaseModel):
title: str = Field(min_length=1, max_length=100)
done: bool = False
@app.post("/todos", status_code=201)
def create_todo(todo: TodoCreate):
return todo- A Pydantic model is like a Zod schema and a TypeScript type in one.
- Because
todohas the typeTodoCreate, FastAPI knows it comes from the JSON body, parses it, and validates it. - No
safeParse, noif (!result.success). If the body is invalid, FastAPI returns a 422 with details about which field is wrong. - Inside the function,
todo.titleis a realstr. Your editor autocompletes it, just likez.infer<typeof TodoCreate>.
| Zod | Pydantic |
|---|---|
z.object({ ... }) |
class Model(BaseModel): ... |
z.string().min(1) |
str = Field(min_length=1) |
z.number().int().positive() |
int = Field(gt=0) |
z.string().optional() |
str | None = None |
z.boolean().default(false) |
bool = False |
z.string().email() |
EmailStr |
z.infer<typeof Schema> |
The class itself is the type |
Schema.parse(data) |
Model.model_validate(data) |
JSON.stringify(obj) |
obj.model_dump_json() |
16. Response models: control what you send back#
You often want the response to have a different shape than the input. For example, a todo in the response has an id, but the client shouldn't send one.
class Todo(TodoCreate): # inherits title and done
id: int
@app.post("/todos", response_model=Todo, status_code=201)
def create_todo(todo: TodoCreate):
return Todo(id=1, **todo.model_dump())class Todo(TodoCreate)is likeTodoCreate & { id: number }in TypeScript.**todo.model_dump()is Python's spread operator, like{ id: 1, ...todo }.response_model=Todomakes FastAPI filter the output to only the fields inTodo. This is great for not leaking things likepassword_hashby accident.
17. Errors: res.status(404) → HTTPException#
Express
app.get("/todos/:id", (req, res) => {
const todo = todos.get(Number(req.params.id));
if (!todo) {
return res.status(404).json({ detail: "Todo not found" });
}
res.json(todo);
});FastAPI
from fastapi import HTTPException
@app.get("/todos/{todo_id}", response_model=Todo)
def get_todo(todo_id: int):
todo = todos.get(todo_id)
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
return todoYou raise an HTTPException, and FastAPI turns it into a JSON response: {"detail": "Todo not found"} with status 404. You can raise it from anywhere, even from helper functions deep in your code.
18. Middleware → dependencies#
In Express, shared logic like "check the auth token" usually goes in middleware that attaches something to req. FastAPI has a cleaner idea: dependencies.
Express
function requireUser(req, res, next) {
if (req.headers.authorization !== "Bearer secret") {
return res.status(401).json({ detail: "Not authenticated" });
}
req.user = "fachalik";
next();
}
app.get("/me", requireUser, (req, res) => {
res.json({ user: req.user });
});FastAPI
from typing import Annotated
from fastapi import Depends, Header, HTTPException
def get_current_user(authorization: Annotated[str | None, Header()] = None) -> str:
if authorization != "Bearer secret":
raise HTTPException(status_code=401, detail="Not authenticated")
return "fachalik"
@app.get("/me")
def read_me(user: Annotated[str, Depends(get_current_user)]):
return {"user": user}Depends(get_current_user)means "run this function first and give me its result".- The dependency's return value comes in as a typed parameter. No more
req.userwith a type ofany. Header()reads theAuthorizationheader for you.- Dependencies can depend on other dependencies. It's the standard way to share things like database sessions, the current user, or settings.
If you've used React hooks, it's a bit like calling useAuth() at the top of a component: the route says what it needs, and the framework provides it.
19. async def or def?#
You've probably noticed I wrote normal def functions. FastAPI supports both:
import httpx
@app.get("/github/{username}")
async def github_user(username: str):
async with httpx.AsyncClient() as client:
res = await client.get(f"https://api.github.com/users/{username}")
return res.json()Here's the simple rule:
- Use
async defwhen youawaitasync libraries (likehttpxfor HTTP calls, or async database drivers). - Use plain
defwhen you call normal, blocking libraries. FastAPI runs these in a thread pool, so they won't freeze your server. - Don't call slow blocking code inside an
async def. It blocks the event loop, just like a giantwhileloop would block Node.
Unlike JS, sync code is totally normal in Python. You don't have to make everything async.
20. CORS: letting your frontend talk to it#
You'll hit this the first time your Next.js or Vite app calls the API. 😅
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)Same idea as the cors package in Express. List only the origins you trust.
21. Bonus: TypeScript types from your API#
Remember /openapi.json? With openapi-typescript, you can turn it into TypeScript types for your frontend:
npx openapi-typescript http://localhost:8000/openapi.json -o src/api/schema.d.tsNow your Pydantic models and your frontend types come from the same source. Change a field in Python, regenerate, and TypeScript tells you everywhere that broke. This is my favorite part of the whole setup. 💙
The full todo API#
Here's everything together. It's one file, with an in-memory "database" so you can focus on FastAPI itself.
# main.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
app = FastAPI(title="Todo API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
class TodoCreate(BaseModel):
title: str = Field(min_length=1, max_length=100)
done: bool = False
class TodoUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=100)
done: bool | None = None
class Todo(TodoCreate):
id: int
todos: dict[int, Todo] = {}
next_id = 1
def find_todo(todo_id: int) -> Todo:
todo = todos.get(todo_id)
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
return todo
@app.get("/todos", response_model=list[Todo])
def list_todos(done: bool | None = None):
items = list(todos.values())
if done is not None:
items = [t for t in items if t.done == done]
return items
@app.get("/todos/{todo_id}", response_model=Todo)
def get_todo(todo_id: int):
return find_todo(todo_id)
@app.post("/todos", response_model=Todo, status_code=201)
def create_todo(data: TodoCreate):
global next_id
todo = Todo(id=next_id, **data.model_dump())
todos[todo.id] = todo
next_id += 1
return todo
@app.patch("/todos/{todo_id}", response_model=Todo)
def update_todo(todo_id: int, data: TodoUpdate):
todo = find_todo(todo_id)
# only the fields the client actually sent, like a partial update
updated = todo.model_copy(update=data.model_dump(exclude_unset=True))
todos[todo_id] = updated
return updated
@app.delete("/todos/{todo_id}", status_code=204)
def delete_todo(todo_id: int):
find_todo(todo_id)
del todos[todo_id]Run uv run fastapi dev main.py, open /docs, and try every endpoint from the browser.
A few things to notice:
global next_idis needed because we change a top-level variable inside a function. In a real app, your database would give you the id.model_dump(exclude_unset=True)returns only the fields the client sent. That's how you do aPATCHwithout overwriting fields withNone.- Next steps for a real project: a database (SQLModel, made by FastAPI's author, is a nice start), tests with
pytest, and splitting routes into files withAPIRouter(likeexpress.Router()).
Tooling cheat sheet#
| What you do in JS/TS | In Python |
|---|---|
pnpm install |
uv sync |
pnpm add zod |
uv add pydantic |
node index.js / tsx index.ts |
uv run main.py |
tsx watch / nodemon |
uv run fastapi dev main.py |
tsc --noEmit |
uvx pyright or uvx mypy . |
| Prettier / Biome format | uvx ruff format |
| ESLint / Biome lint | uvx ruff check |
| Vitest / Jest | uv run pytest |
express.Router() |
APIRouter() |
.env + dotenv |
pydantic-settings |
uvx is like npx: it runs a tool without adding it to your project.
Quick summary#
Things that will feel familiar
- Type hints that look like TypeScript
async/await- Classes,
try/except, imports - FastAPI routes feel like Express routes
- Pydantic feels like Zod
- uv feels like pnpm
Things that are new
- Indentation instead of curly braces
snake_case,True/False/None- Type hints aren't checked by Python, but FastAPI uses them
- List comprehensions instead of
.map()/.filter() - Keyword arguments
- Decorators (
@app.get) - Return a dict instead of calling
res.json() - Dependencies instead of middleware that mutates
req - Sync code is normal, async is a choice
My tips for getting started#
- Use uv from day one. It saves you from the confusing parts of Python packaging.
- Install the Python and Ruff extensions in VS Code, and turn on type checking. Type hints are much more useful when your editor checks them.
- Read the official FastAPI tutorial. It's one of the best framework docs I've read.
- Rebuild an API you already made in Express. Seeing the same thing in both languages is the fastest way to learn.
- Keep
/docsopen while you code. It's the quickest way to test your endpoints.
I'm still new to Python too, but FastAPI made it feel like home much faster than I expected. If you know TypeScript, Express, and Zod, you're already most of the way there.
Happy coding! 🐍
