Python Fundamentals
Variables & Types
No type declarations needed — Python is dynamically typed. But type hints exist (and FastAPI relies on them heavily).
# Basic types
name: str = "Hari"
age: int = 30
price: float = 9.99
active: bool = True
nothing: None = None
# Type is inferred — hints are optional everywhere except FastAPI
count = 42 # still an intpython
Strings
Python's f-strings (prefix f"...") embed expressions directly into string literals — equivalent to C# string interpolation or JS template literals. Triple-quoted strings ("""...""") span multiple lines without escape sequences, making them ideal for SQL queries and multiline messages.
# f-strings (like JS template literals / C# string interpolation)
greeting = f"Hello, {name}. You are {age} years old."
# Multi-line
query = """
SELECT *
FROM users
WHERE active = true
"""
# Common methods
"hello world".upper() # "HELLO WORLD"
"Hello World".lower() # "hello world"
" padded ".strip() # "padded"
"a,b,c".split(",") # ["a", "b", "c"]
",".join(["a", "b", "c"]) # "a,b,c"
"hello".startswith("he") # True
"hello".replace("l", "L") # "heLLo"python
Collections
📋 List []
Like C# List<T> or JS Array
fruits = ["apple", "banana"]
fruits.append("cherry")
fruits.insert(1, "blueberry")
fruits.pop() # remove last
len(fruits) # length
📌 Tuple ()
Immutable list
point = (10, 20)
x, y = point # unpacking
# Great for return values
def get_coords():
return 51.4, -4.3
🗂️ Dict {}
Like C# Dictionary or JS object
user = {"name": "Hari", "role": "admin"}
user["name"] # "Hari"
user.get("email", "N/A") # safe access
user["email"] = "h@x.com" # add/update
del user["email"] # delete
🎯 Set {}
Unique values, fast lookups
tags = {"python", "fastapi"}
tags.add("docker")
tags.discard("fastapi")
"python" in tags # True
Slicing
Python's slice syntax seq[start:stop:step] works on any sequence (lists, strings, tuples). The stop index is exclusive, negative indices count from the end, and omitting a bound defaults to the sequence's start or end. This is far more concise than equivalent loops in C# or JS.
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
nums[2:5] # [2, 3, 4] — start:stop (exclusive)
nums[:3] # [0, 1, 2] — first 3
nums[-3:] # [7, 8, 9] — last 3
nums[::2] # [0, 2, 4, 6, 8] — every 2nd
nums[::-1] # [9, 8, ..., 0] — reversedpython
Control Flow
Unlike C#/JS, Python uses indentation (not braces) to define code blocks — a wrong indent is a syntax error. The ternary expression reads like English: value_if_true if condition else value_if_false. enumerate() gives you both the index and value in a loop, removing the need for a manual counter variable.
# if / elif / else — no braces, indentation matters
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
else:
grade = "C"
# Ternary
status = "active" if is_enabled else "disabled"
# For loops
for item in collection:
print(item)
for i, item in enumerate(collection): # index + value
print(f"{i}: {item}")
for key, value in user.items(): # dict iteration
print(f"{key} = {value}")
# Range
for i in range(5): # 0, 1, 2, 3, 4
for i in range(2, 8): # 2, 3, 4, 5, 6, 7
for i in range(0, 10, 2): # 0, 2, 4, 6, 8python
List Comprehensions
A list comprehension replaces a for loop + append() pattern with a concise one-liner. Adding an if clause filters elements before they are included. The same syntax works for dicts ({k: v for ...}) and sets ({expr for ...}), and using parentheses instead of brackets gives a lazy generator.
# Basic: [expression for item in iterable]
squares = [x**2 for x in range(10)]
# With filter: [expression for item in iterable if condition]
evens = [x for x in range(20) if x % 2 == 0]
# Nested — flatten a matrix
flat = [cell for row in matrix for cell in row]
# Dict & Set comprehensions
squares = {x: x**2 for x in range(5)}
unique = {x.lower() for x in words}python
Functions
Python functions are defined with def and support default parameter values, variadic positional args (*args), and variadic keyword args (**kwargs). Callers can pass any argument by name (keyword), which improves readability but means argument order matters less than in C#. Lambdas are anonymous single-expression functions, equivalent to JS arrow functions.
# Basic
def greet(name: str) -> str:
return f"Hello, {name}"
# Default args
def connect(host: str, port: int = 5432) -> None:
...
# *args and **kwargs (like ...rest in JS)
def log(*args, **kwargs):
print(args) # tuple of positional args
print(kwargs) # dict of keyword args
log("info", "started", level=1, tag="boot")
# Lambda (arrow functions)
double = lambda x: x * 2
sorted_users = sorted(users, key=lambda u: u["name"])python
Unpacking & Destructuring
Python can unpack any iterable into named variables in a single assignment. The starred variable (*rest) acts like a catch-all and is equivalent to JS's rest syntax in destructuring. Dict unpacking with ** merges dictionaries — later keys override earlier ones, making it a clean alternative to Object.assign() or the spread operator.
# Tuple/list unpacking
a, b, c = [1, 2, 3]
first, *rest = [1, 2, 3, 4, 5] # first=1, rest=[2,3,4,5]
# Dict unpacking (like JS spread)
defaults = {"theme": "dark", "lang": "en"}
overrides = {"lang": "fr", "debug": True}
config = {**defaults, **overrides}
# {"theme": "dark", "lang": "fr", "debug": True}python
Core Concepts
Classes
Python classes use __init__ as the constructor, and every instance method receives self as its explicit first argument — there is no implicit this. Class-level variables are shared across all instances (like C# static fields), while instance variables are set on self. Inheritance is declared by putting the parent class in parentheses after the class name.
class Device:
device_count: int = 0 # class var (like static)
def __init__(self, serial: str, name: str):
self.serial = serial
self.name = name
self.status = "offline"
Device.device_count += 1
def activate(self) -> None:
self.status = "online"
# Inheritance
class POSDevice(Device):
def __init__(self, serial: str, name: str, restaurant_id: str):
super().__init__(serial, name)
self.restaurant_id = restaurant_idpython
Dataclasses
Like C# records or DTO classes — auto-generates __init__, __repr__, __eq__
from dataclasses import dataclass, field
@dataclass
class Restaurant:
id: str
name: str
active: bool = True
tags: list[str] = field(default_factory=list)
r = Restaurant(id="r1", name="Pizza Palace")
print(r) # Restaurant(id='r1', name='Pizza Palace', active=True, tags=[])python
Exception Handling
Python's try/except maps directly to C#'s try/catch. You can catch multiple exception types in one clause using a tuple, re-raise with a bare raise, and use else for code that only runs when no exception occurred. Custom exceptions are plain classes that inherit from Exception — no special interface is required.
try:
result = risky_operation()
except ValueError as e:
print(f"Bad value: {e}")
except (KeyError, TypeError):
print("Key or type error")
except Exception as e:
print(f"Unexpected: {e}")
raise # re-raise
else:
print("No error occurred")
finally:
cleanup()
# Custom exceptions
class DeviceNotFoundError(Exception):
def __init__(self, serial: str):
self.serial = serial
super().__init__(f"Device {serial} not found")python
Context Managers
Like C# using / IDisposable — auto-cleanup on exit
# File I/O — auto-closes on exit
with open("data.json", "r") as f:
data = json.load(f)
# Custom context manager
from contextlib import contextmanager
@contextmanager
def db_transaction(conn):
tx = conn.begin()
try:
yield tx
tx.commit()
except:
tx.rollback()
raisepython
Decorators
Like C# attributes, but they actually wrap the function
import functools, time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return result
return wrapper
@timer
def slow_query():
time.sleep(1)
return "done"python
Generators
Lazy iterators — memory efficient for large datasets
# Generator function (yields values one at a time)
def read_large_file(path: str):
with open(path) as f:
for line in f:
yield line.strip()
# Generator expression (like lazy list comprehension)
squares = (x**2 for x in range(1_000_000)) # no memory spikepython
Type Hints
Type hints are annotations only — Python does not enforce them at runtime by default, but tools like Pydantic and FastAPI read them via inspect to do real validation. Use X | None (Python 3.10+) or Optional[X] for nullable types, and built-in generics like list[str] instead of importing List from typing.
from typing import Optional, Callable
# Optional (can be None)
def find_user(id: str) -> dict | None: ...
# Collections
def get_ids() -> list[str]: ...
def get_config() -> dict[str, int]: ...
# Union
def parse(value: str | int) -> float: ...
# Callable
def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
return fn(a, b)python
Async / Await
Same pattern as JS/C# — FastAPI is async-first
import asyncio
async def fetch_data(url: str) -> dict:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.json()
# Run multiple concurrently (like Promise.all)
async def fetch_all():
results = await asyncio.gather(
fetch_data("https://api.example.com/a"),
fetch_data("https://api.example.com/b"),
)
return resultspython
Enums
Mixing str into the Enum base class means each member behaves as a real string — you can compare it directly with string literals and JSON serialisation works automatically. This is the preferred pattern for FastAPI route parameters and Pydantic fields that have a fixed set of allowed values.
from enum import Enum
class DeviceStatus(str, Enum):
ONLINE = "online"
OFFLINE = "offline"
MAINTENANCE = "maintenance"
# str, Enum lets you use it as a string directly
status = DeviceStatus.ONLINE
print(status.value) # "online"
print(status == "online") # True (because of str mixin)python
Virtual Environments
# Create a virtual environment
$ python -m venv .venv
# Activate it
$ source .venv/bin/activate # macOS/Linux
$ .venv\Scripts\activate # Windows
# Install packages
$ pip install fastapi uvicorn[standard] pydantic
# Freeze dependencies
$ pip freeze > requirements.txt
$ pip install -r requirements.txtPydantic
Think of Pydantic models as C# DTOs with built-in validation. They power every request and response in FastAPI.
Basic Model
A Pydantic model is a class that inherits from BaseModel. Fields are declared as type-annotated class attributes, and validation runs automatically when you instantiate the model. Use Field(...) to add constraints — the ... means the field is required (no default). The @field_validator decorator lets you run custom transformation or validation logic on individual fields.
from pydantic import BaseModel, Field, field_validator
from datetime import datetime
class DeviceCreate(BaseModel):
serial: str = Field(..., min_length=5, max_length=50)
name: str
restaurant_id: str
tags: list[str] = []
@field_validator("serial")
@classmethod
def serial_must_be_uppercase(cls, v: str) -> str:
return v.upper()
class DeviceResponse(BaseModel):
id: int
serial: str
name: str
status: DeviceStatus
created_at: datetime
model_config = {"from_attributes": True} # ORM modepython
Nested Models
Pydantic models can be nested by using one model as the type hint for a field in another. FastAPI will accept the corresponding nested JSON object and validate each level separately. This replaces the manual recursive parsing you'd write in C# with a JsonElement or nested DeserializeObject call.
class Address(BaseModel):
street: str
city: str
postcode: str
class RestaurantCreate(BaseModel):
name: str
address: Address
cuisine_tags: list[str] = []
# Accepts nested JSON automatically:
# { "name": "Pizza Palace", "address": {"street": "...", ...} }python
Usage
Instantiating a model validates and coerces input immediately — you get a typed object, not a raw dict. model_dump() converts back to a plain dict for serialisation or database writes, while model_dump_json() returns a JSON string. Validation errors surface as ValidationError with a structured list of per-field failures, which FastAPI automatically converts to a 422 response.
device = DeviceCreate(serial="sn-123", name="Till 1", restaurant_id="r42")
print(device.serial) # "SN-123" (validator ran)
print(device.model_dump()) # → dict
print(device.model_dump_json()) # → JSON string
# Validation error
try:
bad = DeviceCreate(serial="ab", name="X", restaurant_id="r1")
except ValidationError as e:
print(e.errors()) # detailed error listpython
FastAPI
Project Structure
This layout separates concerns the same way ASP.NET Core does: routers act as controllers, services hold business logic, models are ORM entities, and schemas are the Pydantic request/response DTOs. Keeping schemas and models in separate folders prevents accidental coupling between your API surface and your database schema.
project/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app & startup
│ ├── config.py # Settings
│ ├── database.py # DB session
│ ├── models/ # SQLAlchemy / ORM models
│ ├── schemas/ # Pydantic request/response models
│ ├── routers/ # Route handlers (controllers)
│ ├── services/ # Business logic
│ └── middleware/
├── tests/
├── requirements.txt
└── .envstructure
Basic App
The FastAPI() constructor is where you set global metadata that populates the auto-generated Swagger UI at /docs. CORS middleware must be added before any routers are included. include_router() mounts a router under a prefix — equivalent to app.UseEndpoints + MapControllerRoute in ASP.NET Core or app.use() in Express.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(
title="GrubDirect API",
version="1.0.0",
docs_url="/docs", # Swagger UI at /docs
redoc_url="/redoc", # ReDoc at /redoc
)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourdomain.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(devices.router, prefix="/api/devices", tags=["Devices"])
@app.get("/health")
async def health_check():
return {"status": "ok"}app/main.py
# Run it
$ uvicorn app.main:app --reload --port 8000
# Swagger docs → http://localhost:8000/docsRouter (Controller)
An APIRouter groups related endpoints the same way an ASP.NET controller class does. FastAPI reads the type hints on each handler function to derive query parameter names, path parameter types, and the expected request body schema — no attributes or manual binding code is needed. The response_model argument tells FastAPI which Pydantic schema to use for serialisation and output validation.
from fastapi import APIRouter, HTTPException, Query, Path, status
router = APIRouter()
# GET all — with query params
@router.get("/", response_model=list[DeviceResponse])
async def list_devices(
status: str | None = Query(None, description="Filter by status"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
return await device_service.get_all(status=status, skip=skip, limit=limit)
# GET by ID
@router.get("/{device_id}", response_model=DeviceResponse)
async def get_device(device_id: int = Path(..., gt=0)):
device = await device_service.get_by_id(device_id)
if not device:
raise HTTPException(status_code=404, detail="Not found")
return device
# POST — body parsed via Pydantic automatically
@router.post("/", response_model=DeviceResponse, status_code=201)
async def create_device(payload: DeviceCreate):
return await device_service.create(payload)
# DELETE
@router.delete("/{device_id}", status_code=204)
async def delete_device(device_id: int):
if not await device_service.delete(device_id):
raise HTTPException(status_code=404, detail="Not found")app/routers/devices.py
Dependency Injection
FastAPI's DI is elegant — functions injected via Depends()
from fastapi import Depends, Header, HTTPException
# DB session dependency
async def get_db():
db = SessionLocal()
try:
yield db # like a context manager
finally:
db.close()
# Auth dependency
async def get_current_user(authorization: str = Header(...)):
token = authorization.replace("Bearer ", "")
user = verify_token(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid")
return user
# Role guard factory
def require_role(role: str):
def checker(user=Depends(get_current_user)):
if user.role != role:
raise HTTPException(status_code=403)
return user
return checker
# Usage
@router.get("/admin/devices")
async def admin_list(
db=Depends(get_db),
user=Depends(require_role("admin")),
): ...python
Background Tasks
FastAPI's BackgroundTasks runs a function after the HTTP response has been sent, freeing the client immediately. It's suitable for lightweight fire-and-forget work (emails, audit logs, webhooks) but runs in the same process — for CPU-heavy or long-running jobs use Celery or an async task queue instead.
from fastapi import BackgroundTasks
@router.post("/{device_id}/reboot")
async def reboot_device(device_id: int, bg: BackgroundTasks):
await device_service.reboot(device_id)
bg.add_task(send_notification, device_id, "Rebooted")
return {"status": "reboot initiated"}python
WebSockets
FastAPI supports WebSocket connections natively with the same decorator style as HTTP routes. The connection is accepted with websocket.accept() and then held open in an async loop. When the client disconnects, FastAPI raises WebSocketDisconnect, giving you a clean place to run teardown logic such as removing the socket from a connection registry.
from fastapi import WebSocket, WebSocketDisconnect
@app.websocket("/ws/devices/{device_id}")
async def device_ws(websocket: WebSocket, device_id: str):
await websocket.accept()
try:
while True:
data = await websocket.receive_json()
await websocket.send_json({"status": "ok", "echo": data})
except WebSocketDisconnect:
print(f"Device {device_id} disconnected")python
Settings / Config
pydantic_settings.BaseSettings reads values from environment variables (and an optional .env file) and validates them as typed fields — no manual os.getenv() calls scattered across the codebase. Field names are matched case-insensitively, so DATABASE_URL in the environment maps to database_url in the class. Keep a single settings singleton and import it wherever needed.
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite:///./dev.db"
api_key: str
debug: bool = False
cors_origins: list[str] = ["http://localhost:3000"]
model_config = {"env_file": ".env"}
settings = Settings()
# .env file:
# DATABASE_URL=postgresql://user:pass@localhost/dbname
# API_KEY=secret123app/config.py
Database (Async SQLAlchemy)
The async SQLAlchemy setup requires an async-compatible driver (e.g. aiosqlite for SQLite, asyncpg for Postgres) and an AsyncSession instead of the regular Session. ORM models inherit from a shared Base and map to database tables via Column declarations — equivalent to EF Core's DbSet + data annotations approach, but explicit rather than convention-based.
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from sqlalchemy import Column, Integer, String, DateTime, func
engine = create_async_engine("sqlite+aiosqlite:///./app.db")
async_session = sessionmaker(engine, class_=AsyncSession)
class Base(DeclarativeBase): pass
class Device(Base):
__tablename__ = "devices"
id = Column(Integer, primary_key=True, index=True)
serial = Column(String(50), unique=True, nullable=False)
name = Column(String(100), nullable=False)
restaurant_id = Column(String(50), nullable=False)
status = Column(String(20), default="offline")
created_at = Column(DateTime, server_default=func.now())app/database.py
Testing
FastAPI tests use httpx.AsyncClient with an ASGITransport to call the app in-process without starting a real server — equivalent to WebApplicationFactory in ASP.NET Core. The @pytest.fixture decorator creates reusable test setup, and @pytest.mark.anyio enables async test functions. Assert directly on the response's status code and parsed JSON body.
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.mark.anyio
async def test_health(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}tests/test_devices.py
$ pip install pytest pytest-anyio httpx
$ pytest -vQuick Reference
| C# / Node.js | Python |
|---|---|
var / let / const | just assign: x = 5 |
null | None |
Console.WriteLine() / console.log() | print() |
new List<T>() / [] | [] |
Dictionary<K,V> / {} | {} |
.Length / .length | len() |
foreach / for...of | for x in collection: |
async Task<T> / async function | async def fn() -> T: |
using / N/A | with |
interface / type | Protocol or Pydantic BaseModel |
[Attribute] / N/A | @decorator |
throw | raise |
catch | except |
| NuGet / npm | pip / uv |
appsettings.json / .env | pydantic_settings + .env |
| ASP.NET Controller / Express Router | FastAPI APIRouter |
[FromBody] / req.body | Type-hint param as Pydantic model |
[FromQuery] / req.query | Query() |
| Middleware pipeline | add_middleware() |
Useful One-Liners
A collection of compact Python patterns that are commonly needed in API development. The walrus operator (:=, Python 3.8+) assigns and tests a value in a single expression, and the match statement (Python 3.10+) is a structural pattern-match equivalent to C#'s switch expression — both reduce boilerplate in conditional branches.
# Check if key exists in dict
if "name" in user: ...
# Get env var with fallback
import os
db_url = os.getenv("DATABASE_URL", "sqlite:///./dev.db")
# Flatten a list of lists
flat = [item for sub in nested for item in sub]
# Dict from two lists
mapping = dict(zip(keys, vals))
# Remove duplicates preserving order
unique = list(dict.fromkeys(items))
# Safe JSON parse
import json
data = json.loads(raw_string) # str → dict
output = json.dumps(data, indent=2) # dict → str
# Walrus operator (assign in expression)
if (n := len(items)) > 10:
print(f"Too many: {n}")
# Match statement (Python 3.10+)
match status_code:
case 200: print("OK")
case 404: print("Not found")
case _: print(f"Other: {status_code}")python