If you write C# for a living, you've probably had this thought: "I should learn Python — everything AI-shaped seems to speak it." Then you open a tutorial, it spends forty minutes explaining what a variable is, and you close the tab.
This article skips all that. You already know how to program — you need a translation layer, not an introduction. So we'll build something real: a small Products REST API with FastAPI, the same domain as our Modern .NET Backend Roadmap series, and map every Python concept back to the ASP.NET Core idea you already have in your head.
The full working project is in the companion repository — clone it and run along.
Why bother with Python in 2026?
Not because Python is "better" than C# — for building large backends, it usually isn't. You bother because of gravity: the machine-learning and AI tooling ecosystem lives in Python. PyTorch, LangChain, the OpenAI and Anthropic SDKs, pandas, every model fine-tuning script you'll ever copy from a paper — Python first, everything else later (sometimes never).
For a .NET developer the realistic future isn't switching to Python. It's a polyglot setup: your product stays on ASP.NET Core, and a Python service sits next to it doing the ML-flavored work. To build and debug that service confidently, you need working fluency — which one small project gives you.
Setup without the confusion
The number-one Python frustration for .NET developers isn't syntax — it's "which Python am I even running?" In .NET, the SDK is versioned and global.json pins it. Python historically scattered interpreters across your machine and let every project fight over globally installed packages.
The fix is the virtual environment — think of it as a per-project, self-contained runtime plus package folder:
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install fastapi uvicorn
Mental model for the pieces:
.venvis roughly yourbin/+ a private NuGet cache — a folder you never commit.pipis NuGet;requirements.txtis your.csprojpackage list (pip install -r requirements.txt≈dotnet restore).uvicornis Kestrel — the ASGI web server that hosts your app.- Activating the venv just points
pythonandpipat the project-local copies. Deactivate withdeactivate.
That's the entire ceremony. If a tutorial tells you to sudo pip install anything globally, run away.
The cheat sheet
Pin this mapping to your monitor and 80% of Python reads instantly:
| You know (C#/.NET) | You'll use (Python) |
|---|---|
var x = 5; (inferred, static) |
x = 5 (dynamic, but see type hints) |
string Name { get; set; } |
name: str (a type hint — advisory) |
| Controller / minimal API endpoint | function with a route decorator |
| DTO record + data annotations | Pydantic BaseModel + Field |
IEnumerable<T> + LINQ Select/Where |
list + comprehension [p.name for p in products if p.stock > 0] |
NuGet + .csproj |
pip + requirements.txt |
| Kestrel | uvicorn |
xUnit + WebApplicationFactory |
pytest + TestClient |
async Task<T> / await |
async def / await |
null |
None |
Exceptions (throw) |
Exceptions (raise) |
Two honest differences worth internalizing rather than fighting. First, type hints don't enforce anything at runtime — name: str is documentation the interpreter ignores. Tooling (your IDE, mypy) checks them statically, and Pydantic enforces them at the API boundary, which in practice is where it matters. Second, indentation is syntax — blocks are defined by whitespace, not braces. You'll stop noticing within an hour.
Build it: a Products API in ~40 lines
Here's the complete service — every line annotated in your language:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI(title="Products API", version="1.0") # ≈ WebApplication.CreateBuilder().Build()
class ProductIn(BaseModel): # your request DTO
name: str = Field(min_length=2, max_length=80) # [StringLength(80, MinimumLength = 2)]
price: float = Field(gt=0) # [Range(0.01, double.MaxValue)]
stock: int = Field(ge=0, default=0)
class Product(ProductIn): # response DTO inherits + adds the id
id: int
_db: dict[int, Product] = {} # in-memory store; a real app uses SQLAlchemy
_next_id = 1
@app.get("/products", response_model=list[Product]) # ≈ [HttpGet] on a controller action
def list_products() -> list[Product]:
return list(_db.values())
@app.post("/products", response_model=Product, status_code=status.HTTP_201_CREATED)
def create_product(data: ProductIn) -> Product: # body binding happens by type, like [FromBody]
global _next_id
product = Product(id=_next_id, **data.model_dump())
_db[_next_id] = product
_next_id += 1
return product
@app.post("/products/{product_id}/reserve", response_model=Product)
def reserve_product(product_id: int, quantity: int = 1) -> Product: # route + query binding by name
if product_id not in _db:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
product = _db[product_id]
if product.stock < quantity:
raise HTTPException(status.HTTP_409_CONFLICT, "Not enough stock")
product.stock -= quantity
return product
The decorator @app.get("/products") is doing what [HttpGet("/products")] does — registering the function with the router. Parameters bind exactly the way ASP.NET Core binds them: path parameters by name from the route, simple types from the query string, and a Pydantic model from the JSON body.
Run it:
uvicorn app.main:app --reload # --reload ≈ dotnet watch
Then open http://127.0.0.1:8000/docs — FastAPI generates a live Swagger UI from your types, no Swashbuckle configuration, no attributes. This is the moment FastAPI usually wins .NET developers over.
Pydantic is your model binding + validation
Post an invalid body and watch what happens:
curl -X POST http://127.0.0.1:8000/products \
-H "content-type: application/json" \
-d '{"name": "X", "price": 0}'
You get a structured 422 response listing both violations — name too short, price not greater than zero — without writing a single line of validation code. Pydantic read the Field constraints from the model, exactly like ASP.NET Core reads data annotations and returns its ValidationProblemDetails 400.
The **data.model_dump() idiom deserves a note: model_dump() turns the model into a dictionary, and ** spreads it into constructor arguments — the closest C# analogue is a with expression on a record: data with { Id = nextId }.
Testing — pytest instead of xUnit
The test experience will feel familiar, minus the class ceremony:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app) # ≈ WebApplicationFactory<Program>().CreateClient()
def test_create_and_reserve():
created = client.post("/products", json={"name": "4K monitor", "price": 329.99, "stock": 3})
assert created.status_code == 201
reserved = client.post(f"/products/{created.json()['id']}/reserve", params={"quantity": 2})
assert reserved.json()["stock"] == 1
No [Fact], no assertion library — pytest discovers any function named test_* and rewrites the plain assert statement to produce rich failure messages. Run pytest and both tests in the companion repo pass in well under a second.
When to reach for Python — and when to stay on .NET
An honest scorecard from someone who ships both:
Reach for Python when the job is ML/AI integration (calling models, embeddings, LangChain-style orchestration), data wrangling (pandas beats anything in .NET for exploratory work), scripting and automation, or gluing together research code that only exists in Python.
Stay on ASP.NET Core for the product backend: the type system catches whole categories of bugs at compile time, performance is significantly better for API workloads, and large-codebase refactoring in a dynamic language is genuinely painful. EF Core, DI, and the hosting model are also simply more complete than their Python counterparts.
The pattern that works: .NET core product, Python sidecar for the AI parts, talking over plain HTTP — your ASP.NET Core app calls the FastAPI service with HttpClient like any other dependency. You now know how to build both sides.
Clone the companion repository, break things, and check the cheat sheet when something looks alien. Fluency comes fast when every new idea has a hook to hang on.
Comments (0)
No comments yet — be the first to share your thoughts.