Leadership & Career

Learning Python as an Experienced C# Developer: Translation Notes and Exercises

Afzal AhmedFaz Ahmed
·27 July 2026·29 min read
PythonC#.NETFastAPIpytestpandasJupyterPydanticType HintsData Engineering

Why This Matters

My learning journal for translating familiar .NET ideas into Python’s execution model, typing, data structures, testing, FastAPI, pandas, automation and packaging.

Let’s approach this as a proper mentoring session.

I am not going to teach you what a variable or loop is. I am assuming you already think comfortably in C# and understand architecture, APIs, SQL, testing, debugging, deployment and production support. Our goal is to transfer that engineering judgement into Python quickly and safely.

Python is not harder than C#. In many places it is smaller, looser and faster to write. That looseness is also why an experienced engineer should learn its mechanics rather than treating it as C# with the punctuation removed. I want your Python to be readable, testable, typed where useful, correctly packaged, isolated in a virtual environment and appropriate for the job—whether that is data work, an API, automation or Azure AI.

That is the level we want.


1. Why should a C# developer learn Python?

A C# developer usually comes from a world of structure.

You have:

Solution
  Projects
    Controllers
    Services
    Repositories
    DbContext
    DTOs
    appsettings.json
    Program.cs
    Dependency Injection
    NuGet packages
    Build output

Python has equivalent ideas, but the ceremony is lighter.

You have:

Project folder
  package
    modules
    classes
    functions
  pyproject.toml
  .venv
  requirements.txt
  tests
  scripts

Python is especially strong in data science, automation, scripting, AI, notebooks, APIs, ETL, machine-learning workflows and quick integration tasks. Its portability, coherent syntax and enormous library ecosystem explain why it appears across web development, data platforms, system integration and automation.

So why Python and not only C# for data science?

Not because C# is weak. C# is excellent for enterprise systems, APIs, cloud services, domain modelling, high-performance backends and large corporate applications. But Python became the common language of data science because the ecosystem formed around it: Jupyter notebooks, pandas, NumPy, scikit-learn, PyTorch, TensorFlow, matplotlib, seaborn, statsmodels, FastAPI, data cleaning libraries, notebook-based exploration and AI tooling.

Jupyter Notebook and pandas give us a productive environment for data exploration. A pandas DataFrame provides matrix-like data processing, while NumPy and lower-level implementations perform much of the heavy numerical work underneath.

That is the sweet spot.

C# is fantastic when the system is already known and needs to be engineered.

Python is fantastic when the data is messy, the question is exploratory, and you need to investigate quickly.

A senior full-stack developer with C# plus Python becomes dangerous in a good way: C# for robust production systems, Python for AI/data/automation pipelines, and both together for serious enterprise AI.


2. Bootstrapping Python: the equivalent of creating a .NET project

In C#, you may do this:

dotnet new webapi -n LoanRiskApi
cd LoanRiskApi
dotnet run

In Python, the first professional habit is creating a virtual environment.

Virtual environments matter. They isolate each project’s packages from the system Python installation and from other applications. The standard venv module is a sound place to begin.

Think of it like this:

C#:
  Project uses NuGet packages from .csproj

Python:
  Project uses pip packages inside .venv

A clean Python project starts like this:

mkdir loan-risk-python
cd loan-risk-python

# Create virtual environment
python -m venv .venv

# Activate on Windows PowerShell
.\.venv\Scripts\activate

# Activate on macOS/Linux
source .venv/bin/activate

# Install packages
pip install fastapi uvicorn pandas pytest

# Save dependencies
pip freeze > requirements.txt

Your prompt now usually shows something like:

(.venv) PS C:\code\loan-risk-python>

That tells you the project environment is active.

In C#, your packages live in NuGet and are restored using:

dotnet restore

In Python, another developer clones your project and does:

python -m venv .venv
.\.venv\Scripts\activate
pip install -r requirements.txt

Modern Python increasingly uses pyproject.toml for project metadata, dependencies, scripts, entry points and build configuration.

A professional project may look like this:

loan_risk_app/
  pyproject.toml
  README.md
  .env
  src/
    loan_risk/
      __init__.py
      main.py
      models.py
      services.py
      repository.py
  tests/
    test_risk_service.py

Compare this with C#:

LoanRiskApp/
  LoanRiskApp.csproj
  Program.cs
  appsettings.json
  Controllers/
  Services/
  Models/
  Tests/

Same thinking. Less ceremony.


3. Running Python: scripts, shell, services and APIs

In C#, execution begins with Program.cs.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello from C#");

app.Run();

In Python, a file can be run directly:

print("Hello from Python")

Save it as:

main.py

Run:

python main.py

But in a real application, you normally protect the entry point like this:

# main.py

def main() -> None:
    print("Application started")


if __name__ == "__main__":
    main()

That line looks strange to a C# developer:

if __name__ == "__main__":

Think of it as:

Only run this block when this file is executed directly,
not when it is imported by another module.

C# has a clear compiled application entry point. Python files can be both executable scripts and importable modules. So Python gives you this pattern to separate “run as app” from “use as library.”

Python can run as a script, in an interactive shell or behind a service. A .py file is a module, while a package groups related modules into a coherent unit.

A simple Python package:

loan_risk/
  __init__.py
  main.py
  risk.py
# loan_risk/risk.py

def calculate_risk_score(income: float, debt: float) -> float:
    if income <= 0:
        raise ValueError("Income must be greater than zero")

    debt_to_income = debt / income

    if debt_to_income < 0.25:
        return 10.0

    if debt_to_income < 0.45:
        return 50.0

    return 90.0
# loan_risk/main.py

from loan_risk.risk import calculate_risk_score


def main() -> None:
    score = calculate_risk_score(income=60000, debt=18000)
    print(f"Risk score: {score}")


if __name__ == "__main__":
    main()

Run:

python -m loan_risk.main

The -m means “run this module as a script.” It is closer to running an application by module path rather than raw file path.


4. Runtime mechanics: C# compiler/CLR versus Python interpreter/CPython

Now let’s talk mechanics.

In C#, you write:

int age = 42;
Console.WriteLine(age);

The C# compiler compiles your code into IL. The CLR loads assemblies, verifies metadata, JIT-compiles methods to native code, manages memory, exceptions, GC and type safety.

Python is different.

In Python:

age = 42
print(age)

There is normally no explicit build step like the one you know from .NET. The standard Python implementation produces bytecode that the interpreter runs. That aids portability, although ordinary Python code is typically slower than code compiled directly to machine instructions.

The normal implementation is CPython.

Mental model:

C#:
  .cs source
    ↓ Roslyn compiler
  IL + metadata
    ↓ CLR/JIT
  native machine execution

Python:
  .py source
    ↓ CPython compiles to bytecode
  .pyc bytecode
    ↓ Python virtual machine/interpreter
  executed dynamically

This is why Python feels quick to develop. You edit and run. No build ceremony.

But it is also why Python needs different performance thinking.

C# often gives you speed from compiled execution and strong static typing.

Python gives you speed of development, dynamic expressiveness and library power. For data science, much of the heavy lifting is not done in slow Python loops: NumPy and pandas call optimised native implementations underneath, and performance-critical extensions can integrate code written in lower-level languages.

That is the key.

Bad Python data science:

total = 0

for value in huge_list:
    total += value

Better Python data science:

import numpy as np

values = np.array([10, 20, 30, 40])
total = values.sum()

You are not writing Python loops for millions of rows. You are expressing operations and letting optimized libraries do the work.

Very similar to how in C# you do not fetch 10 million rows into memory and loop if SQL can aggregate it.


5. Names, objects and variables: Python is not C# with missing types

C#:

int age = 42;
age = 43;

The variable age has a declared type: int.

Python:

age = 42
age = 43

In Python, age is a name pointing to an object. Every object has an identity, a type and a value. If you later assign 43, you are rebinding the name rather than changing the integer object 42.

This is a huge mental shift.

In C#, we often think:

variable contains value

In Python, think:

name points to object

Example:

age = 42

print(id(age))     # identity of the object
print(type(age))   # <class 'int'>
print(age)         # value

Now with a mutable object:

numbers = []

print(id(numbers))

numbers.append(10)
numbers.append(20)

print(id(numbers))     # same ID
print(numbers)         # [10, 20]

The list object changed internally. Same object, different content.

That brings us to one of Python’s most important concepts: mutability.


6. Mutability: the bug factory if you don’t understand it

C# developers already understand reference types and value types. Python has a different but related issue: mutable and immutable objects.

Immutable examples:

int
float
bool
str
tuple
frozenset

Mutable examples:

list
dict
set
bytearray
custom objects

If an object’s value can change, it is mutable; otherwise it is immutable. This distinction affects function calls, copying, defaults and how we reason about state.

Python:

name = "Faz"
name = name.upper()

print(name)  # "FAZ"

The original string did not change. Strings are immutable. upper() returned a new string.

But:

roles = ["Admin", "Manager"]
roles.append("Underwriter")

print(roles)  # ["Admin", "Manager", "Underwriter"]

The list changed.

Here is the dangerous Python function mistake:

def add_role(role: str, roles: list[str] = []) -> list[str]:
    roles.append(role)
    return roles


print(add_role("Admin"))       # ['Admin']
print(add_role("Manager"))     # ['Admin', 'Manager']  <-- surprise

A C# developer will look at that and say: “Why did it remember the old value?”

Because default parameter values in Python are evaluated once when the function is defined, not every time it is called.

Correct version:

def add_role(role: str, roles: list[str] | None = None) -> list[str]:
    if roles is None:
        roles = []

    roles.append(role)
    return roles

My rule of thumb:

Never use mutable objects like [] or {} as default argument values.


7. Syntax comparison: C# versus Python basics

C#:

var amount = 1000m;
var riskBand = "Low";

if (amount > 50000)
{
    riskBand = "High";
}
else if (amount > 10000)
{
    riskBand = "Medium";
}
else
{
    riskBand = "Low";
}

Python:

amount = 1000
risk_band = "Low"

if amount > 50_000:
    risk_band = "High"
elif amount > 10_000:
    risk_band = "Medium"
else:
    risk_band = "Low"

Notice the differences:

Python uses indentation, not braces. Python uses elif, not else if. Python uses snake_case by convention. Python does not require semicolons. Python does not declare variable types by default.

A loop in C#:

foreach (var loan in loans)
{
    Console.WriteLine(loan.Reference);
}

Python:

for loan in loans:
    print(loan.reference)

A C# dictionary:

var applicant = new Dictionary<string, object>
{
    ["name"] = "Faz",
    ["income"] = 60000,
    ["approved"] = true
};

Python dictionary:

applicant = {
    "name": "Faz",
    "income": 60_000,
    "approved": True,
}

Python’s dictionary appears everywhere and underpins much of the language’s object model. Learn it as a first-class tool rather than an occasional collection.

That is not an exaggeration. Python objects internally rely heavily on dictionary-like namespaces.


8. Functions: Python’s real building blocks

C# method:

public decimal CalculateMonthlyPayment(decimal principal, decimal annualRate, int months)
{
    var monthlyRate = annualRate / 12 / 100;
    return principal * monthlyRate / (1 - (decimal)Math.Pow((double)(1 + monthlyRate), -months));
}

Python function:

def calculate_monthly_payment(
    principal: float,
    annual_rate: float,
    months: int,
) -> float:
    monthly_rate = annual_rate / 12 / 100
    return principal * monthly_rate / (1 - (1 + monthly_rate) ** -months)

Functions reduce duplication, split complex work and hide implementation detail. Python’s positional and keyword arguments, unpacking, keyword-only parameters and multiple return values are worth learning on their own terms.

Python has very flexible parameters.

def create_loan(
    applicant_name: str,
    amount: float,
    *,
    product_code: str,
    broker_id: str | None = None,
) -> dict:
    return {
        "applicant_name": applicant_name,
        "amount": amount,
        "product_code": product_code,
        "broker_id": broker_id,
    }

The * means everything after it must be passed by keyword:

loan = create_loan(
    "Faz Ahmed",
    250_000,
    product_code="BRIDGE-12",
    broker_id="BROKER-001",
)

This improves readability.

Bad:

loan = create_loan("Faz Ahmed", 250000, "BRIDGE-12", "BROKER-001")

Good:

loan = create_loan(
    "Faz Ahmed",
    250_000,
    product_code="BRIDGE-12",
    broker_id="BROKER-001",
)

For a senior developer, Python’s keyword arguments are like self-documenting method calls.


9. Type hints: Python can be dynamic and still professional

A C# developer often gets nervous when Python has no mandatory compile-time types.

C#:

public sealed record LoanApplication(
    Guid Id,
    string ApplicantName,
    decimal Amount,
    LoanStatus Status);

Python with type hints and dataclass:

from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from uuid import UUID


class LoanStatus(Enum):
    DRAFT = "Draft"
    SUBMITTED = "Submitted"
    APPROVED = "Approved"
    REJECTED = "Rejected"


@dataclass(frozen=True)
class LoanApplication:
    id: UUID
    applicant_name: str
    amount: Decimal
    status: LoanStatus

This feels closer to C#.

Type hints are now established professional practice. Annotations, unions, generics, protocols and tools such as Mypy communicate intent and catch many mistakes without changing Python’s dynamic runtime model.

An important point from experience:

Python type hints do not normally enforce types at runtime by themselves.

They help:

IDEs. Linters. Static checkers like mypy. Frameworks like FastAPI and Pydantic. Readability. Refactoring. Team communication.

Example:

def approve_loan(loan: LoanApplication) -> LoanApplication:
    if loan.status != LoanStatus.SUBMITTED:
        raise ValueError("Only submitted loans can be approved")

    return LoanApplication(
        id=loan.id,
        applicant_name=loan.applicant_name,
        amount=loan.amount,
        status=LoanStatus.APPROVED,
    )

Now a C# developer can reason about the code properly.

You should use type hints in professional Python. Not because Python becomes C#, but because large systems need clarity.


10. Classes, dataclasses, properties and OOP

C# class:

public sealed class RiskCalculator
{
    public string CalculateRiskBand(decimal income, decimal debt)
    {
        var ratio = debt / income;

        if (ratio < 0.25m) return "Low";
        if (ratio < 0.45m) return "Medium";

        return "High";
    }
}

Python class:

class RiskCalculator:
    def calculate_risk_band(self, income: float, debt: float) -> str:
        ratio = debt / income

        if ratio < 0.25:
            return "Low"

        if ratio < 0.45:
            return "Medium"

        return "High"

The first thing a C# developer notices is self.

In Python, instance methods explicitly receive the current object as the first parameter. By convention, it is called self.

C# hides this.

Python makes self visible.

A more Pythonic domain model:

from dataclasses import dataclass
from decimal import Decimal


@dataclass
class Applicant:
    name: str
    annual_income: Decimal
    total_debt: Decimal

    @property
    def debt_to_income_ratio(self) -> Decimal:
        if self.annual_income == 0:
            return Decimal("999")

        return self.total_debt / self.annual_income

Usage:

applicant = Applicant(
    name="Faz Ahmed",
    annual_income=Decimal("60000"),
    total_debt=Decimal("15000"),
)

print(applicant.debt_to_income_ratio)

Python supports the OOP ideas you already know—classes, inheritance, composition, polymorphism, properties and operator overloading—while dataclasses provide a concise way to express data-focused types.

A word of caution: Python supports OOP, but not every idea needs a class. Sometimes a function is the clearer design.

C# culture often leans toward classes and interfaces.

Python culture often asks:

“Is this better as a small function?”

That is not weakness. That is Pythonic design.


11. Decorators: Python attributes plus middleware plus filters, in one mental bucket

In C#, you use attributes:

[Authorize]
[HttpGet("{id}")]
public async Task<ActionResult<LoanDto>> GetLoan(Guid id)
{
    ...
}

In Python, decorators look like this:

@app.get("/loans/{loan_id}")
def get_loan(loan_id: str):
    return {"loan_id": loan_id}

Decorators become much less mysterious when you connect them to middleware, filters and attributes you already know.

A decorator wraps a function and changes or extends its behaviour.

Simple example:

from functools import wraps
from time import perf_counter


def measure_time(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = perf_counter()

        try:
            return func(*args, **kwargs)
        finally:
            elapsed = perf_counter() - start
            print(f"{func.__name__} took {elapsed:.4f}s")

    return wrapper


@measure_time
def calculate_report() -> None:
    total = sum(range(1_000_000))
    print(total)


calculate_report()

This is similar in spirit to middleware, filters, interceptors or attributes in C#.

Decorators are heavily used in frameworks:

FastAPI:

@app.get("/stations")
def get_stations():
    ...

Pytest:

@pytest.mark.parametrize("amount,expected", [(100, "Low"), (50000, "High")])
def test_risk(amount, expected):
    ...

Caching:

from functools import cached_property

My rule of thumb:

A decorator is not magic. It is a function receiving a function and returning another function.


12. Comprehensions and generators: compact data transformations

C# LINQ:

var approvedLoans = loans
    .Where(x => x.Status == LoanStatus.Approved)
    .Select(x => x.Amount)
    .ToList();

Python list comprehension:

approved_amounts = [
    loan.amount
    for loan in loans
    if loan.status == LoanStatus.APPROVED
]

This is one of the most Pythonic features.

Dictionary comprehension:

loan_lookup = {
    loan.id: loan
    for loan in loans
}

Set comprehension:

unique_brokers = {
    loan.broker_id
    for loan in loans
}

Comprehensions, map, zip, filter and generators form Python’s toolkit for expressive transformations and lazy iteration.

Generators are lazy.

C#:

public IEnumerable<int> GetNumbers()
{
    yield return 1;
    yield return 2;
    yield return 3;
}

Python:

def get_numbers():
    yield 1
    yield 2
    yield 3

A real example:

def read_large_file(path: str):
    with open(path, "r", encoding="utf-8") as file:
        for line in file:
            yield line.strip()

Usage:

for row in read_large_file("applications.csv"):
    if row:
        print(row)

This avoids loading the whole file into memory.

My rule of thumb:

Use lists when you need the whole collection now.

Use generators when you want streaming, lazy processing or memory efficiency.


13. Exceptions and context managers: Python’s using

C#:

using var stream = File.OpenRead(path);

Python:

with open("loans.csv", "r", encoding="utf-8") as file:
    content = file.read()

The with statement is Python’s context manager pattern. It ensures setup and cleanup.

Python supports raising and handling exceptions, custom exception types, tracebacks, exception groups and both class- and generator-based context managers.

Python exception:

class LoanValidationError(Exception):
    pass


def validate_amount(amount: float) -> None:
    if amount <= 0:
        raise LoanValidationError("Loan amount must be greater than zero")

Usage:

try:
    validate_amount(-10)
except LoanValidationError as error:
    print(f"Validation failed: {error}")

Context manager:

from contextlib import contextmanager


@contextmanager
def audit_scope(operation: str):
    print(f"Starting {operation}")

    try:
        yield
        print(f"Completed {operation}")
    except Exception:
        print(f"Failed {operation}")
        raise


with audit_scope("loan approval"):
    validate_amount(250_000)

This is conceptually similar to wrapping operations in logging, transaction scope or middleware.


14. Files, JSON, HTTP and persistence

A senior C# developer spends a lot of time moving data around: files, JSON, APIs, SQL, streams, configs.

Python’s libraries cover files and directories, binary data, path manipulation, temporary files, compression, JSON, streams, HTTP, databases and configuration formats such as INI and TOML.

Python JSON:

import json
from dataclasses import asdict, dataclass
from decimal import Decimal


@dataclass
class LoanExport:
    reference: str
    applicant_name: str
    amount: str


loan = LoanExport(
    reference="LN-1001",
    applicant_name="Faz Ahmed",
    amount=str(Decimal("250000.00")),
)

payload = json.dumps(asdict(loan), indent=2)
print(payload)

Reading JSON:

import json

with open("loan.json", "r", encoding="utf-8") as file:
    data = json.load(file)

print(data["applicant_name"])

HTTP request:

import requests

response = requests.get("https://api.example.com/loans")
response.raise_for_status()

loans = response.json()

In C#, you may use HttpClient.

In Python, requests is the classic library for simple sync HTTP. For async workloads, you might use httpx or aiohttp.


15. Testing: pytest as your xUnit mental model

C# xUnit:

[Fact]
public void CalculateRiskBand_ReturnsHigh_WhenDebtIsLarge()
{
    var calculator = new RiskCalculator();

    var result = calculator.CalculateRiskBand(60000m, 40000m);

    Assert.Equal("High", result);
}

Python with pytest:

from loan_risk.risk import calculate_risk_band


def test_calculate_risk_band_returns_high_when_debt_is_large():
    result = calculate_risk_band(income=60_000, debt=40_000)

    assert result == "High"

Parameterized test:

import pytest

from loan_risk.risk import calculate_risk_band


@pytest.mark.parametrize(
    "income,debt,expected",
    [
        (60_000, 10_000, "Low"),
        (60_000, 20_000, "Medium"),
        (60_000, 40_000, "High"),
    ],
)
def test_calculate_risk_band(income, debt, expected):
    assert calculate_risk_band(income, debt) == expected

Run:

pytest

The same testing discipline applies in Python: focused tests, clear assertions, sensible boundaries, mocks where they add value and TDD when it improves the design.

Mocking example:

from unittest.mock import Mock


def test_service_uses_repository():
    repository = Mock()
    repository.get_total_debt.return_value = 20_000

    service = LoanRiskService(repository)

    result = service.calculate("APP-001", income=60_000)

    assert result == "Medium"
    repository.get_total_debt.assert_called_once_with("APP-001")

My rule of thumb:

Do not treat Python as “just scripts.” Serious Python gets tests.


16. Debugging and profiling: where Python feels very practical

Python gives us practical debugging and profiling tools: tracebacks, logging, pdb, assertions, tests, monitoring, profiling and focused execution-time measurements.

The simplest debugging:

print(f"{loan_id=}")
print(f"{risk_score=}")

Python f-strings allow self-documenting expressions:

risk_score = 72
print(f"{risk_score=}")  # risk_score=72

Timing code:

from time import perf_counter

start = perf_counter()

# operation
sum(range(10_000_000))

elapsed = perf_counter() - start
print(f"Elapsed: {elapsed:.4f}s")

Logging:

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def approve_loan(reference: str) -> None:
    logger.info("Approving loan %s", reference)

Python debugger:

breakpoint()

This drops you into an interactive debugging session when the code runs.

For senior developers, the key is the same as C#:

Do not guess performance problems. Measure them.


17. FastAPI: Python’s modern API framework

Now let’s talk full application development.

FastAPI offers a modern route into Python API development with typed models, clear status codes, dependency patterns, authentication and generated documentation.

FastAPI feels natural to a C# developer because it uses type annotations heavily.

ASP.NET Core:

app.MapGet("/loans/{id}", async (Guid id, ILoanService service) =>
{
    var loan = await service.GetAsync(id);
    return loan is null ? Results.NotFound() : Results.Ok(loan);
});

FastAPI:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from uuid import UUID

app = FastAPI()


class LoanDto(BaseModel):
    id: UUID
    applicant_name: str
    amount: float
    status: str


fake_db: dict[UUID, LoanDto] = {}


@app.get("/loans/{loan_id}", response_model=LoanDto)
def get_loan(loan_id: UUID) -> LoanDto:
    loan = fake_db.get(loan_id)

    if loan is None:
        raise HTTPException(status_code=404, detail="Loan not found")

    return loan

FastAPI uses decorators such as app.get() to associate functions with HTTP methods and routes. APIRouter, Depends, Pydantic models and environment-based configuration will feel familiar once mapped to ASP.NET Core concepts.

A proper FastAPI structure:

src/
  loan_api/
    main.py
    config.py
    schemas.py
    services.py
    repository.py
    routers/
      loans.py
tests/
  test_loans.py

config.py:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")

    database_url: str
    debug: bool = False
    api_version: str = "1.0.0"


settings = Settings()

schemas.py:

from decimal import Decimal
from enum import Enum
from pydantic import BaseModel
from uuid import UUID


class LoanStatus(str, Enum):
    submitted = "Submitted"
    approved = "Approved"
    rejected = "Rejected"


class LoanCreateRequest(BaseModel):
    applicant_name: str
    amount: Decimal


class LoanResponse(BaseModel):
    id: UUID
    applicant_name: str
    amount: Decimal
    status: LoanStatus

main.py:

from fastapi import FastAPI

from loan_api.routers.loans import router as loans_router

app = FastAPI(title="Loan API", version="1.0.0")

app.include_router(loans_router)

routers/loans.py:

from decimal import Decimal
from uuid import UUID, uuid4

from fastapi import APIRouter, HTTPException

from loan_api.schemas import LoanCreateRequest, LoanResponse, LoanStatus

router = APIRouter(prefix="/loans", tags=["Loans"])

loans: dict[UUID, LoanResponse] = {}


@router.post("", response_model=LoanResponse, status_code=201)
def create_loan(request: LoanCreateRequest) -> LoanResponse:
    loan = LoanResponse(
        id=uuid4(),
        applicant_name=request.applicant_name,
        amount=request.amount,
        status=LoanStatus.submitted,
    )

    loans[loan.id] = loan

    return loan


@router.get("/{loan_id}", response_model=LoanResponse)
def get_loan(loan_id: UUID) -> LoanResponse:
    loan = loans.get(loan_id)

    if loan is None:
        raise HTTPException(status_code=404, detail="Loan not found")

    return loan


@router.post("/{loan_id}/approve", response_model=LoanResponse)
def approve_loan(loan_id: UUID) -> LoanResponse:
    loan = loans.get(loan_id)

    if loan is None:
        raise HTTPException(status_code=404, detail="Loan not found")

    approved = loan.model_copy(update={"status": LoanStatus.approved})
    loans[loan_id] = approved

    return approved

Run:

uvicorn loan_api.main:app --reload

Then open:

http://localhost:8000/docs

FastAPI generates Swagger/OpenAPI documentation from type annotations and Pydantic models, giving consumers a discoverable contract.

This is why Python is not just scripts anymore. You can build serious APIs.


18. Data science: where Python becomes the tool of choice

Now imagine the business asks:

“Can you analyse 50,000 loan applications and tell us which broker channels produce the highest default risk?”

In C#, you can do it. You might write LINQ, SQL, export CSV, use Power BI, maybe use ML.NET.

In Python, the workflow is often faster:

import pandas as pd

df = pd.read_csv("loan_applications.csv")

df.head()
df.describe()

Filter:

high_value = df[df["amount"] > 500_000]

Group:

default_rate_by_broker = (
    df.groupby("broker_name")["defaulted"]
    .mean()
    .sort_values(ascending=False)
)

Create new metric:

df["debt_to_income"] = df["total_debt"] / df["annual_income"]

Clean missing data:

df = df.dropna(subset=["annual_income", "total_debt"])

Export:

df.to_csv("cleaned_loan_applications.csv", index=False)

A realistic data workflow prepares and cleans input, creates a DataFrame, reshapes fields, computes metrics, saves the result and visualises it.

This is the reason Python wins in this space.

You do not write a full application first. You explore.

In a Jupyter notebook:

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("loan_applications.csv")

summary = (
    df.groupby("status")["amount"]
    .agg(["count", "sum", "mean"])
    .sort_values("sum", ascending=False)
)

summary

Plot:

summary["sum"].plot(kind="bar", title="Loan Amount by Status")
plt.show()

The developer can investigate data, ask questions, clean data, visualize patterns, then later move useful logic into production code.

Senior C# developer mapping:

SQL Server:
  best for durable relational data and serious querying

C#:
  best for business systems, APIs, transactions, domain workflows

Python:
  best for exploration, data cleaning, ML/AI workflows, automation, notebooks

Power BI:
  best for business dashboards

Azure:
  best for production hosting, integration and governance

Do not see Python as replacing C#. See it as adding a data and AI workshop next to your enterprise engineering toolkit.


19. CLI applications: Python as your developer automation weapon

Python is also excellent for command-line tools with positional arguments, options, subcommands, API clients, configuration and secrets.

C# has console apps. Python is brilliant for quick CLI tools.

Example:

import argparse
import csv
from decimal import Decimal


def calculate_total(path: str) -> Decimal:
    total = Decimal("0")

    with open(path, "r", encoding="utf-8") as file:
        reader = csv.DictReader(file)

        for row in reader:
            total += Decimal(row["amount"])

    return total


def main() -> None:
    parser = argparse.ArgumentParser(description="Loan CSV utility")
    parser.add_argument("path", help="Path to loan CSV file")

    args = parser.parse_args()

    total = calculate_total(args.path)
    print(f"Total amount: ${total:,.2f}")


if __name__ == "__main__":
    main()

Run:

python loan_total.py loans.csv

This is where Python is very productive. Need to rename files? Parse logs? Call APIs? Clean CSVs? Generate reports? Python is perfect.


20. Packaging: from script to professional Python application

A senior C# developer knows the difference between a console experiment and a production package.

In C#:

.csproj
NuGet packages
dotnet build
dotnet publish

In Python:

pyproject.toml
src layout
dependencies
entry points
build package
publish to PyPI or internal feed

Example pyproject.toml:

[project]
name = "loan-risk"
version = "1.0.0"
description = "Loan risk utilities"
requires-python = ">=3.12"
dependencies = [
    "pydantic",
    "fastapi",
    "uvicorn",
]

[project.scripts]
loan-risk = "loan_risk.cli:main"

CLI entry point:

# src/loan_risk/cli.py

def main() -> None:
    print("Loan risk CLI")

Install locally in editable mode:

pip install -e .

Run:

loan-risk

Professional packaging covers project layout, editable development installs, metadata, dependencies, entry points, builds and publishing through the Python Package Index when appropriate.

That is when your Python project becomes a reusable professional artefact, not just a folder of scripts.


21. A small but complete Python application: loan risk service

Let’s now tie this together.

Project:

loan-risk/
  pyproject.toml
  src/
    loan_risk/
      __init__.py
      models.py
      risk.py
      api.py
  tests/
    test_risk.py

models.py:

from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from uuid import UUID


class RiskBand(str, Enum):
    LOW = "Low"
    MEDIUM = "Medium"
    HIGH = "High"


@dataclass(frozen=True)
class Applicant:
    id: UUID
    name: str
    annual_income: Decimal
    total_debt: Decimal
    requested_amount: Decimal

risk.py:

from decimal import Decimal

from loan_risk.models import Applicant, RiskBand


class RiskCalculator:
    def calculate(self, applicant: Applicant) -> RiskBand:
        if applicant.annual_income <= 0:
            raise ValueError("Annual income must be greater than zero")

        debt_to_income = applicant.total_debt / applicant.annual_income
        loan_to_income = applicant.requested_amount / applicant.annual_income

        if debt_to_income < Decimal("0.25") and loan_to_income < Decimal("4"):
            return RiskBand.LOW

        if debt_to_income < Decimal("0.45") and loan_to_income < Decimal("6"):
            return RiskBand.MEDIUM

        return RiskBand.HIGH

api.py:

from decimal import Decimal
from uuid import UUID, uuid4

from fastapi import FastAPI
from pydantic import BaseModel

from loan_risk.models import Applicant, RiskBand
from loan_risk.risk import RiskCalculator

app = FastAPI(title="Loan Risk API", version="1.0.0")


class RiskRequest(BaseModel):
    name: str
    annual_income: Decimal
    total_debt: Decimal
    requested_amount: Decimal


class RiskResponse(BaseModel):
    applicant_id: UUID
    risk_band: RiskBand


calculator = RiskCalculator()


@app.post("/risk", response_model=RiskResponse)
def calculate_risk(request: RiskRequest) -> RiskResponse:
    applicant = Applicant(
        id=uuid4(),
        name=request.name,
        annual_income=request.annual_income,
        total_debt=request.total_debt,
        requested_amount=request.requested_amount,
    )

    risk_band = calculator.calculate(applicant)

    return RiskResponse(
        applicant_id=applicant.id,
        risk_band=risk_band,
    )

Run:

uvicorn loan_risk.api:app --reload

Test:

from decimal import Decimal
from uuid import uuid4

from loan_risk.models import Applicant, RiskBand
from loan_risk.risk import RiskCalculator


def test_calculate_low_risk():
    applicant = Applicant(
        id=uuid4(),
        name="Faz Ahmed",
        annual_income=Decimal("100000"),
        total_debt=Decimal("10000"),
        requested_amount=Decimal("250000"),
    )

    calculator = RiskCalculator()

    result = calculator.calculate(applicant)

    assert result == RiskBand.LOW

This little app demonstrates:

Project structure. Models. Enums. Dataclasses. Decimal for finance. Service class. FastAPI endpoint. Pydantic request/response models. Testing.

That is how a C# developer should learn Python: not by memorising toys, but by building familiar business logic.


22. Mentoring build: make the loan-risk service production-shaped

The small application proves syntax and structure. It does not yet prove that we can operate a Python service. Let us improve it through a conversation I often have with developers moving from C#.

Junior: The endpoint works and the test passes. Should we add repository, unit-of-work and service interfaces now?
>
Senior: Not automatically. Start from the next risks: input rules, configuration, dependency failure, concurrency, diagnostics and packaging. Add a boundary where one of those pressures needs it.
The first design decision is that the calculator remains a pure function. It needs no database, network or clock, so it does not require dependency injection.
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum


class RiskBand(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"


@dataclass(frozen=True, slots=True)
class RiskFactors:
    annual_income: Decimal
    total_debt: Decimal
    requested_amount: Decimal


@dataclass(frozen=True, slots=True)
class RiskDecision:
    band: RiskBand
    debt_to_income: Decimal
    loan_to_income: Decimal
    reason_code: str


def calculate_risk(factors: RiskFactors) -> RiskDecision:
    if factors.annual_income <= 0:
        raise ValueError("annual_income must be greater than zero")
    if factors.total_debt < 0 or factors.requested_amount <= 0:
        raise ValueError("debt cannot be negative and request must be positive")

    debt_ratio = factors.total_debt / factors.annual_income
    loan_ratio = factors.requested_amount / factors.annual_income

    if debt_ratio < Decimal("0.25") and loan_ratio < Decimal("4"):
        return RiskDecision(RiskBand.LOW, debt_ratio, loan_ratio, "within_low_limits")
    if debt_ratio < Decimal("0.45") and loan_ratio < Decimal("6"):
        return RiskDecision(RiskBand.MEDIUM, debt_ratio, loan_ratio, "manual_review")
    return RiskDecision(RiskBand.HIGH, debt_ratio, loan_ratio, "limits_exceeded")

This example remains illustrative rather than financial advice. In a real lending system, authorised policy owners define calculations, rounding, effective dates and explanations.

frozen=True prevents normal attribute assignment, while slots=True avoids a per-instance attribute dictionary and rejects accidental new attributes. Neither makes nested mutable objects magically immutable. The fields here use immutable Decimal and enum values, so the value object is a good fit.

Junior: Why a function instead of IRiskCalculator and RiskCalculator?
>
Senior: Python functions are first-class dependencies. The calculation has no state and one clear behaviour. Introduce a protocol when several collaborators genuinely need a contract, not to reproduce a C# shape mechanically.

23. Structural typing with Protocol

When the service calls an external fraud provider, a boundary is useful. Python’s Protocol supports structural typing: an object satisfies the protocol when it has compatible members, without declaring that it implements an interface.

from dataclasses import dataclass
from typing import Protocol


@dataclass(frozen=True, slots=True)
class FraudResult:
    score: int
    reference: str


class FraudGateway(Protocol):
    async def check(
        self,
        *,
        applicant_id: str,
        idempotency_key: str,
    ) -> FraudResult: ...

A fake in a test and an HTTP implementation can both conform. A static type checker catches incompatible signatures. Runtime code does not automatically validate the protocol, so tests and boundary validation still matter.

class StubFraudGateway:
    def __init__(self, result: FraudResult) -> None:
        self.result = result
        self.calls: list[tuple[str, str]] = []

    async def check(
        self,
        *,
        applicant_id: str,
        idempotency_key: str,
    ) -> FraudResult:
        self.calls.append((applicant_id, idempotency_key))
        return self.result

Keyword-only parameters after * reduce argument-order mistakes. That is especially valuable when several parameters share the same primitive type. In C#, value objects or named arguments can provide similar clarity.

Do not create a protocol for every class. Use one where a caller depends on behaviour owned at an external or variable boundary. An internal dataclass used directly by one module usually needs no abstraction.

24. Dependency wiring without recreating a container

FastAPI can provide dependencies per request. Keep construction visible:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import httpx
from fastapi import Depends, FastAPI


class HttpFraudGateway:
    def __init__(self, client: httpx.AsyncClient) -> None:
        self._client = client

    async def check(self, *, applicant_id: str, idempotency_key: str) -> FraudResult:
        response = await self._client.post(
            "/checks",
            json={"applicant_id": applicant_id},
            headers={"Idempotency-Key": idempotency_key},
        )
        response.raise_for_status()
        payload = response.json()
        return FraudResult(score=int(payload["score"]), reference=payload["reference"])


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    async with httpx.AsyncClient(
        base_url=settings.fraud_base_url,
        timeout=httpx.Timeout(3.0),
    ) as client:
        app.state.fraud_gateway = HttpFraudGateway(client)
        yield


app = FastAPI(lifespan=lifespan)


def get_fraud_gateway() -> FraudGateway:
    return app.state.fraud_gateway

The lifespan owns the client lifecycle. We do not create an HTTP client for every request. Settings and credentials should be loaded and validated at startup, with secrets kept outside source control.

For a larger system, an application factory avoids relying on a module-global app and makes tests clearer:

def create_app(settings: Settings) -> FastAPI:
    @asynccontextmanager
    async def lifespan(app: FastAPI) -> AsyncIterator[None]:
        async with build_http_client(settings) as client:
            app.state.fraud_gateway = HttpFraudGateway(client)
            yield

    application = FastAPI(title="Loan Risk API", lifespan=lifespan)
    application.include_router(risk_router)
    return application
Junior: ASP.NET Core gives me scoped, transient and singleton lifetimes. Where are they here?
>
Senior: Python frameworks can model lifetimes, but do not search for a one-to-one container translation. Ask who creates the object, who shares it, who closes it and whether concurrent requests can use it safely.

25. Async Python is cooperative, just like the important part of async C#

async def creates a coroutine function. Calling it returns a coroutine object; execution advances when it is awaited by an event loop. This resembles Task-based asynchronous work conceptually, but the APIs and runtime differ.

Async helps when tasks spend time waiting for network or other asynchronous I/O. It does not make CPU-heavy Python calculation parallel. Blocking the event-loop thread with synchronous I/O or long CPU work delays other requests.

@router.post("/risk", response_model=RiskResponse)
async def assess_risk(
    request: RiskRequest,
    fraud: FraudGateway = Depends(get_fraud_gateway),
) -> RiskResponse:
    decision = calculate_risk(request.to_factors())
    fraud_result = await fraud.check(
        applicant_id=str(request.applicant_id),
        idempotency_key=request.idempotency_key,
    )
    return RiskResponse.from_results(decision, fraud_result)

The calculation is small enough to run inline. A large numerical workload may move to a process pool, batch job or specialised worker. Measure before moving it. Calling a blocking database driver inside async def defeats concurrency; use an async driver or deliberately execute blocking work outside the event-loop thread with bounded capacity.

Cancellation is cooperative. If the client disconnects or the server cancels the task, awaited operations may raise asyncio.CancelledError. Do not swallow cancellation in a broad handler.

import asyncio


async def call_provider() -> FraudResult:
    try:
        async with asyncio.timeout(3):
            return await gateway.check(
                applicant_id=applicant_id,
                idempotency_key=operation_key,
            )
    except TimeoutError as exc:
        raise FraudProviderUnavailable("fraud check timed out") from exc

An ambiguous timeout is still ambiguous: the remote system may have accepted the operation. Reuse the same idempotency key and understand the provider contract before retrying. Async syntax does not solve distributed consistency.

Junior: Can I use asyncio.gather for every independent call?
>
Senior: Only if concurrency is safe, bounded and cancellation semantics are understood. Starting ten thousand requests simultaneously is not performance engineering.
Use a semaphore, worker queue or library limits where input size can grow. In modern Python, structured concurrency tools such as task groups can make sibling-task failure and cancellation easier to reason about than detached tasks. Never create a background task from a request and forget it; process exit, worker restart and exceptions can lose work. Durable jobs belong in a queue with ownership and retry policy.

26. Pydantic models are boundary models, not the domain

FastAPI commonly uses Pydantic for request parsing, validation and schema generation. That does not mean the Pydantic request object should flow through every layer.

from decimal import Decimal
from typing import Annotated

from pydantic import BaseModel, ConfigDict, Field


MoneyAmount = Annotated[Decimal, Field(gt=0, max_digits=14, decimal_places=2)]


class RiskRequest(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)

    applicant_id: UUID
    annual_income: MoneyAmount
    total_debt: Annotated[Decimal, Field(ge=0, max_digits=14, decimal_places=2)]
    requested_amount: MoneyAmount
    idempotency_key: Annotated[str, Field(min_length=16, max_length=100)]

    def to_factors(self) -> RiskFactors:
        return RiskFactors(
            annual_income=self.annual_income,
            total_debt=self.total_debt,
            requested_amount=self.requested_amount,
        )

extra="forbid" catches misspelled or unexpected fields instead of ignoring them silently. Boundary validation improves error messages and rejects obviously invalid data early. Domain validation still protects calls from other entry points and rules involving existing state.

Do not use binary floating point for contractual money calculations. Decimal avoids common representation surprises, but the team must still define scale, currency and rounding. JSON numbers may arrive through clients with different precision behaviour; contract examples and tests should cover this.

Avoid returning exception text directly. Map expected failures into a stable error contract and log unexpected failures with a correlation identifier. Pydantic’s validation errors may reveal request structure but should not echo secrets.

27. Python exceptions, chaining and boundary translation

Python’s raise NewError(...) from exc preserves causal context. Use it when translating a low-level failure into application vocabulary.

class FraudProviderUnavailable(RuntimeError):
    pass


async def obtain_fraud_result(...) -> FraudResult:
    try:
        return await gateway.check(...)
    except httpx.TimeoutException as exc:
        raise FraudProviderUnavailable("fraud provider timed out") from exc

Catch exceptions where you can add policy, recovery or translation. A broad except Exception: return None destroys meaning and can turn a dependency outage into an incorrect low-risk decision.

Use finally or a context manager for cleanup. Context managers encode acquisition and release together:

from contextlib import asynccontextmanager


@asynccontextmanager
async def transaction(session):
    try:
        yield session
        await session.commit()
    except BaseException:
        await session.rollback()
        raise

The exact database library may already provide transaction context, which is preferable to recreating it. Notice BaseException in cleanup: cancellation-related exceptions must still roll back, while business handlers normally catch narrower Exception subclasses. Understand the library’s documented behaviour rather than copying this sketch blindly.

At the HTTP boundary, map validation to 400 or 422 according to the public contract, missing resources to 404, state conflicts to 409 and temporary dependencies to 503. Authentication and authorisation need their own 401/403 handling. Unexpected defects remain 500 and trigger operational attention.

28. Configuration should fail before traffic arrives

Environment variables are strings and may be absent or malformed. Parse them into one validated settings object at startup.

from pydantic import HttpUrl, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="LOAN_RISK_",
        env_file=None,
        extra="ignore",
    )

    environment: str
    fraud_base_url: HttpUrl
    fraud_api_key: SecretStr
    request_timeout_seconds: float = 3.0
    maximum_parallel_provider_calls: int = 50

Production should obtain secrets from the platform’s secret mechanism or workload identity. A local uncommitted developer file can be convenient, but do not build production behaviour around it. Ensure SecretStr is never deliberately unwrapped into logs.

Validate relationships too: timeout must be positive, concurrency within an approved range, environment in an allow-list, and production URLs must use HTTPS. Fail startup with a precise error. A container that reports healthy and fails on its first request is harder to operate.

Unlike .NET’s options pattern, Python has no single universal configuration convention. That flexibility makes a written project rule important. One settings object, explicit construction and no scattered os.getenv calls create a reliable boundary.

29. Logging and tracing without leaking applications

Python’s standard logging is capable, but production logs need structure and context. Record stable fields such as trace ID, operation, safe applicant reference, result category, dependency and duration. Do not format a whole request object into the message.

logger.info(
    "risk_assessment_completed",
    extra={
        "trace_id": trace_id,
        "applicant_ref_hash": applicant_reference_hash,
        "risk_band": decision.band,
        "reason_code": decision.reason_code,
        "duration_ms": duration_ms,
    },
)

If the chosen logging library supports structured event arguments directly, use its conventions. Configure redaction centrally. Exceptions should include stack traces only in protected logs, never API responses.

Trace across incoming HTTP, model or provider calls, database operations and queued work using standard correlation propagation where the infrastructure supports it. Metrics should cover request rate, errors, latency, provider timeout, result distribution and queue depth. A sudden shift from 10% to 90% high-risk may be a business event, bad input mapping or policy bug even when every HTTP request returns 200.

Health checks have distinct meanings. Liveness asks whether the process should restart. Readiness asks whether it should receive traffic. Do not make liveness depend on every remote provider, which can create a restart storm during an external outage. Readiness policy depends on whether the service can degrade safely.

30. pytest beyond the first happy-path test

Use parametrisation to make boundaries visible:

import pytest


@pytest.mark.parametrize(
    ("debt_ratio", "loan_ratio", "expected"),
    [
        ("0.24", "3.99", RiskBand.LOW),
        ("0.25", "3.99", RiskBand.MEDIUM),
        ("0.44", "5.99", RiskBand.MEDIUM),
        ("0.45", "5.99", RiskBand.HIGH),
    ],
)
def test_risk_boundaries(debt_ratio: str, loan_ratio: str, expected: RiskBand) -> None:
    income = Decimal("100000")
    factors = RiskFactors(
        annual_income=income,
        total_debt=income * Decimal(debt_ratio),
        requested_amount=income * Decimal(loan_ratio),
    )

    assert calculate_risk(factors).band is expected

Fixtures should express reusable setup, not hide the important values. Scope matters: a session-scoped mutable fixture can leak state between tests. Prefer fresh data unless startup cost justifies sharing, and reset shared infrastructure explicitly.

Monkeypatching is powerful but can patch the wrong import location. If service.py uses from clock import utc_now, patch the name used by service, not the original module and hope lookup changes. Better yet, pass a clock function to code where time is a business dependency.

Integration tests should run the real ASGI application, database engine and migrations where those contracts matter. Fake external HTTP with a controlled server and assert serialisation, timeout and unknown responses. Do not mock a database query and claim its SQL works.

Property-based tests are valuable for numeric invariants. Generate valid incomes and debts, then assert that increasing debt while other values remain fixed never improves the risk band—if that monotonic property is truly part of policy. Such tests explore combinations humans do not enumerate, but they require a clear invariant.

31. Debugging clinic: a default list leaks between requests

This classic bug surprises developers from C#:

def add_warning(message: str, warnings: list[str] = []) -> list[str]:
    warnings.append(message)
    return warnings

Default expressions are evaluated when the function is defined, not on every call. The same list is reused.

def add_warning(
    message: str,
    warnings: list[str] | None = None,
) -> list[str]:
    result = [] if warnings is None else list(warnings)
    result.append(message)
    return result

Copying also makes the function avoid mutating its caller’s list. Whether to mutate or return a new collection is an API decision; make it clear in naming and documentation.

Dataclasses guard against a similar mutable-default mistake by requiring a factory:

from dataclasses import dataclass, field


@dataclass
class AssessmentTrace:
    warnings: list[str] = field(default_factory=list)

When diagnosing state leakage, print or inspect id(value) to see whether two names reference the same object. Trace where mutation occurs. Do not scatter deep copies everywhere; that hides ownership and can be expensive. Prefer immutable values at boundaries and explicit mutation inside a small owner.

Junior: Why does Python allow such a dangerous default?
>
Senior: Because definition-time evaluation is consistent and sometimes useful. The language expects us to understand object lifetime. Every language has sharp edges; professional practice turns them into tests and conventions.

32. Debugging clinic: an innocent import fails in production

Python executes module top-level code on first import and caches the module. Circular imports occur when modules depend on each other during that incomplete initialisation.

api imports service
service imports models
models imports api for a shared enum
api is only partly initialised

The fix is usually architectural: move shared domain types into a lower-level module that imports neither API nor infrastructure. Avoid performing network calls, reading mandatory files or constructing the whole application as an import side effect.

Absolute imports within an installed package are easier to reason about than manipulating sys.path. Run modules through the package (python -m loan_risk...) and install the project into the virtual environment. A script that works only when launched from one directory has an accidental execution contract.

Import cycles can also expose misplaced responsibilities. If models require the API layer for validation, boundary concerns have leaked inward. Use dependency direction to simplify, not to manufacture a large C#-style layer hierarchy.

33. Packaging and reproducible builds

Treat pyproject.toml as the project’s build and tool configuration centre. Declare package metadata, supported Python range, runtime dependencies and optional development groups using the selected build tooling. Commit the appropriate lock file when the workflow supports one.

A production build should create a wheel in clean CI, install it into a clean environment, run tests against the installed artefact and generate a dependency inventory. Do not deploy a developer’s working folder with undeclared imports.

source checkout
  -> create isolated environment
  -> install locked build/test dependencies
  -> static type check and lint
  -> unit and integration tests
  -> build wheel
  -> install wheel into clean image
  -> smoke test
  -> scan and publish immutable image

Pinning every transitive version forever is not the goal; reproducibility and deliberate updates are. Use an automated dependency-update process, review changelogs, run the suite and rebuild regularly. Hash verification and a controlled package index can strengthen supply-chain policy.

Do not name a local module json.py, typing.py or after another dependency. It can shadow the standard or installed package depending on import paths, producing confusing behaviour. Include an import smoke test from outside the repository directory.

Containers should run as a non-root user, contain only runtime dependencies, expose a defined command and handle termination signals. The number of worker processes depends on CPU, memory, workload and hosting platform; benchmark rather than copying a magic formula. Each worker has separate Python memory and connection pools.

34. Performance: measure Python rather than apologising for it

Start with end-to-end latency and throughput. In an API that spends most time waiting on a provider, database or network, switching language will not repair a slow dependency. Trace first.

For CPU work, profile representative inputs. cProfile can locate cumulative function cost; sampling profilers reduce instrumentation distortion; allocation tools help find memory growth. Microbenchmarks need warm-up, repeated runs and isolation from unrelated load.

Generators can reduce peak memory by producing items lazily:

def parse_valid_rows(lines: Iterable[str]) -> Iterator[RiskFactors]:
    for line in lines:
        if not line.strip():
            continue
        yield parse_risk_factors(line)

Laziness changes lifetime. If the generator reads an open file or database cursor, consumption must occur before its context closes. It can also postpone an exception until iteration, which affects error handling.

CPython’s global interpreter lock affects execution of Python bytecode across threads in many common deployments and versions, but it is not a licence for simplistic rules. Threads remain useful for blocking I/O; async supports high-concurrency cooperative I/O; processes can provide CPU parallelism with serialisation and memory costs; native numerical libraries may release runtime locks internally. Choose from measurement and the actual interpreter/runtime.

Optimise algorithms and data movement before syntax. Avoid materialising millions of objects when a database aggregation, vectorised library operation or streaming transform can do the work closer to the data. Keep a clear version first and retain performance tests around any less-readable optimisation.

35. Choosing honestly between Python and C#

The decision is not which language is “enterprise.” Both can run production systems. Compare the workload and team.

Python is often the shortest path when the required library ecosystem is strongest there: machine learning, scientific computing, notebooks, data processing, automation and many AI examples. C# may be the natural choice for an existing .NET platform with mature domain code, operational standards and developer expertise.

A mixed architecture can be sensible: a Python service owns model training or specialised inference while ASP.NET Core owns the broader workflow. That boundary adds network failure, contracts, deployment, observability and security. Do not split languages merely to use a favourite syntax.

Junior: Should we rewrite the loan API in Python because the future risk model uses Python?
>
Senior: Not necessarily. Keep the boundary around the capability that needs Python. A model can be exported, served independently or invoked through a job. Compare latency, ownership and operational cost before moving stable business workflows.
The best language is the one that lets the owning team deliver and operate the required capability safely. Existing code, hiring, incident response, libraries, compliance and deployment platform all count. A benchmark of a tiny loop does not answer that organisational question.

36. Exercises for the C# developer

Exercise one: references and mutation

Create a list of dictionaries, bind it to two names and mutate a nested value. Then use a shallow copy and repeat. Explain why the nested dictionary remains shared. Replace the structure with frozen dataclasses and identify what is truly immutable.

Exercise two: build a typed gateway

Define a Protocol for a read-only exchange-rate provider. Implement an HTTP adapter and an in-memory fake. Run a static type checker, then deliberately break the return type. Add runtime validation for malformed provider JSON.

Exercise three: observe event-loop blocking

Create an async endpoint that calls a blocking sleep, then load-test concurrent requests. Replace it with an awaited sleep and compare. Next add CPU-heavy work and decide whether a bounded process worker or offline job is appropriate.

Exercise four: test ambiguous retry

Build a fake server that records a POST then drops the connection. Retry under one stable idempotency key. Prove one logical operation exists, and return a conflict when the key is reused for a different applicant.

Exercise five: package from clean state

Build a wheel, install it into a new environment from outside the repository and run a smoke test. Remove an undeclared dependency from the build environment and prove the clean test catches it. Record the exact build inputs.

Exercise six: diagnose a production trace

Simulate rising high-risk decisions with normal HTTP health. Compare input distribution, application version, calculation reason codes and provider scores. Decide whether the cause is policy, mapping, data or traffic before changing the algorithm.

37. Cross-links for continuing the mentoring path

After this guide, use Pragmatic TDD in C# and .NET to compare test design rather than only framework syntax. The C# Async/Await, Tasks and ASP.NET Core guide deepens the .NET side of concurrency. Learning Machine Learning Engineering on AWS follows the lifecycle when a Python model moves from notebook to governed production system. HTTP and Web APIs from First Principles strengthens the network boundary shared by FastAPI and ASP.NET Core. Web Security for Full-Stack Developers develops the trust-boundary and data-protection concerns only introduced here.

The useful learning loop is to implement the same small capability in both languages, then compare the resulting contracts, tests, traces and deployment—not just line count. That turns familiarity with C# into a lens for understanding Python while allowing Python’s own strengths to remain visible.

38. Database work: explicit transaction ownership

Python database libraries vary, but the consistency questions are familiar. Keep a session or connection scoped to one application operation, define where the transaction begins and ends, and do not hold it open across a slow external call.

An application service might separate the provider operation from the database commit:

async def assess_application(
    command: AssessCommand,
    gateway: FraudGateway,
    sessions: SessionFactory,
) -> AssessmentResult:
    async with sessions() as read_session:
        snapshot = await load_snapshot(
            read_session,
            application_id=command.application_id,
            tenant_id=command.tenant_id,
        )

    fraud = await gateway.check(
        applicant_id=str(snapshot.applicant_id),
        idempotency_key=command.idempotency_key,
    )

    async with sessions() as write_session:
        async with write_session.begin():
            application = await load_for_update(
                write_session,
                application_id=command.application_id,
                tenant_id=command.tenant_id,
            )
            application.ensure_version(snapshot.version)
            decision = calculate_risk(application.to_factors())
            application.record(decision, fraud.reference)
            write_session.add(to_outbox_message(application))

    return AssessmentResult.from_domain(application)

The sketch deliberately reloads before writing. State may change during the network call. Optimistic version checking prevents a stale calculation from overwriting a newer update. The domain must decide whether an old fraud result remains reusable after particular changes.

Do not catch a concurrency exception and automatically rerun the whole method without reconsidering the provider side effect. Reuse the operation key and reload business state. A retry policy that is correct for a SELECT may be dangerous around a paid POST.

An ORM does not remove SQL knowledge. Inspect generated SQL, query plans, index use, round trips and result size. Lazy loading can produce N+1 queries when a loop touches relationships. Select only the projection required for read endpoints. Integration-test using the actual database engine when transaction or SQL behaviour matters; an in-memory replacement can hide collation, constraint and isolation differences.

Connection pools are finite. Each worker process may own a pool, so multiplying web workers can multiply connections beyond database capacity. Capacity planning should connect replicas, workers, pool size and background jobs. A timeout waiting for a pool connection is different from a slow query and should be observable as such.

39. Type hints are executable communication, not runtime armour

Type hints improve editor help, refactoring and static analysis, but normal Python does not enforce them automatically at every call.

def percentage(part: Decimal, whole: Decimal) -> Decimal:
    return part / whole

# Python can still attempt this at runtime unless another boundary rejects it.
percentage("10", "20")

A type checker identifies the call in analysed code. External JSON, database rows, reflection and untyped libraries remain runtime inputs. Validate them at boundaries.

Use precise types where they clarify ownership:

from typing import NewType, TypedDict

ApplicantId = NewType("ApplicantId", str)
ApplicationId = NewType("ApplicationId", str)


class ProviderPayload(TypedDict):
    reference: str
    score: int

NewType helps static analysis distinguish two string identifiers but is not a rich runtime value object. If parsing and invariants matter, use a small dataclass or validated model. TypedDict describes dictionary shape for checking; it does not validate provider JSON at runtime.

Generics can retain relationships between inputs and outputs, but avoid turning straightforward Python into type-level puzzles. If annotations are harder to understand than the behaviour, reconsider the API. Configure one checker consistently in CI, decide how strict new code should be, and isolate untyped dependencies behind typed adapters.

Junior: C# nullable reference types warn me about missing values. Is str | None equivalent?
>
Senior: It expresses the same important possibility to a checker, but enforcement depends on analysis coverage and configuration. Handle None deliberately and validate runtime inputs; do not mistake annotation for a guard.
Avoid Any spreading through the application. It disables useful checking downstream. Parse an untyped payload once into a known model. Sometimes a targeted cast is necessary, but it tells the checker what you assert—it performs no runtime conversion. Add a test or validation that proves the assertion.

40. Data science handoff: notebook to tested module

Python’s notebooks are excellent for exploring risk data, plotting distributions and testing hypotheses. Their hidden state makes them a weak production boundary. A cell can depend on another cell run three hours earlier in a different order.

The handoff should separate exploration from repeatable transformation:

notebooks/
  explore_risk_distribution.ipynb
src/loan_risk/features/
  build.py
tests/features/
  test_build.py
pipelines/
  prepare_dataset.py

Move important calculations into functions with typed inputs, explicit configuration and tests. The notebook imports those functions. A clean kernel should run from first cell to last against a versioned sample. Production uses the module or pipeline, not automated mouse clicks through the notebook.

DataFrames introduce their own contracts. Column names, dtypes, nullability, time zones and units should be checked before calculation. A column containing numeric-looking strings may work in one expression and fail or concatenate in another. Define schema expectations and fail with useful diagnostics.

REQUIRED_COLUMNS = {
    "application_id",
    "observed_at",
    "annual_income",
    "total_debt",
    "outcome",
}


def validate_columns(frame: pd.DataFrame) -> None:
    missing = REQUIRED_COLUMNS - set(frame.columns)
    if missing:
        raise DatasetContractError(f"missing columns: {sorted(missing)}")
    if frame["application_id"].duplicated().any():
        raise DatasetContractError("application_id must be unique")

For machine learning, split by the reality the model will face. Randomly mixing future and past can leak policy or customer changes. Record dataset version, code commit, parameters and metrics. The Python model artefact then becomes one versioned dependency of the wider system, not magic produced by an individual notebook.

41. Incident clinic: memory grows until a worker restarts

Suppose production memory climbs steadily while request volume remains stable. Container restarts temporarily fix it. The tempting explanation is “Python garbage collection is bad,” but we need evidence.

First graph resident memory, Python heap allocation if available, request rate, response size, worker identity and recent release events. Determine whether every worker grows, only one route triggers growth, and whether growth correlates with a cache or background job.

Create a repeatable load test with representative payloads. Compare snapshots of allocated objects. Common causes include an unbounded dictionary cache, retained task references, accumulating logging handlers, a global list used for diagnostics, large responses stored in traces, or a third-party native allocation.

Imagine the culprit is this convenience cache:

assessment_cache: dict[str, RiskDecision] = {}


def remember(application_id: str, decision: RiskDecision) -> None:
    assessment_cache[application_id] = decision

It has no size, expiry, tenant policy or invalidation. Every processed application remains reachable, so garbage collection correctly keeps it.

The fix begins with the requirement. If repeated access is rare, remove the cache. If caching creates measured value, use a bounded implementation with expiry and metrics, decide whether entries may contain sensitive data, and understand that every worker has a separate cache. A distributed cache adds network failure and serialisation, so it also needs justification.

Junior: Could we force gc.collect() after each request?
>
Senior: That treats the symptom and adds latency. Reachable objects are not collectible. Find who owns the reference and correct the lifetime.
After the fix, keep the load test and an alert on sustained memory slope, not merely a high absolute value. Validate graceful worker shutdown and readiness so a real exhaustion event does not drop requests unexpectedly.

42. Incident clinic: async endpoint is slower under load

Another incident shows good single-request latency but severe degradation at concurrency fifty. Tracing reveals a synchronous HTTP library called inside async def. Each call blocks the event-loop worker.

Confirm with a controlled test rather than changing syntax blindly. Capture event-loop lag, dependency duration and throughput. Replace the call with an async client using one lifecycle-managed pool, then repeat under the same conditions.

If no async library exists, run the blocking call in a bounded thread pool. “Bounded” matters: shifting unlimited blocking work to threads moves the exhaustion problem. Set concurrency based on downstream limits and reject or queue excess work predictably.

The inverse mistake also happens: developers turn every function into async def even when it performs no awaits. That creates coroutine plumbing without concurrency benefit. Pure calculations remain ordinary functions and can be called from async endpoints.

Monitor the whole queueing path. A provider may allow only twenty concurrent requests; increasing our workers to two hundred can increase timeouts and retry traffic. Backpressure is a feature. Return overload responses or use a durable queue rather than accepting unlimited work that cannot finish before client deadlines.

43. A production-readiness review for the Python service

Before release, I would ask the junior developer to demonstrate evidence:

  • A clean environment can install the built artefact from declared dependencies.
  • Static checking, linting, unit, integration and API contract tests pass in CI.
  • Settings validation fails startup when required values are missing.
  • Request models reject unexpected and out-of-range data.
  • Authentication and resource-level authorisation are tested.
  • HTTP clients, database sessions and background resources have explicit lifetimes.
  • Every network call has a deadline; retries are bounded and semantically safe.
  • Duplicate commands and competing updates have defined behaviour.
  • Logs and traces carry correlation without sensitive request dumps.
  • Health checks, metrics, alerts and runbooks reflect actual dependencies.
  • The image runs without administrative privileges and shuts down gracefully.
  • Database migration and API changes support rolling deployment and rollback.
  • Dependency updates, vulnerability findings and Python runtime upgrades have owners.
The review should include a live failure exercise. Stop the fraud-provider fake, send a request, inspect the response and trace, restore the dependency and retry with the same key. Then terminate a worker during a request and confirm the platform and client behaviour. A diagram is useful, but recovery evidence is stronger.

The service also needs ownership boundaries. Who owns the calculation? Who approves a threshold change? Who supports the provider integration? Who can deploy? Who can view sensitive traces? Python’s ease of editing must not turn production policy into an unreviewed script.

Junior: When can I say I am productive in Python?
>
Senior: When you can predict its object and async behaviour, use its tools naturally, build a reproducible artefact, diagnose a failure, and choose a simple Python design without translating every C# habit literally.
For a final self-review, take one ordinary pull request and ask five questions. Have I made mutation and ownership obvious? Do type hints describe real possibilities such as None, or merely make the editor quiet? Are external values parsed once at the boundary? Can cancellation, timeout or retry repeat a side effect? Could a new engineer build and run this code from the declared project files alone?

Then read the code in Python terms. A short module with functions and dataclasses may be more maintainable than a hierarchy of services. A comprehension may clarify a small transformation, while a normal loop may better expose validation and diagnostics. A context manager may express resource lifetime more reliably than a distant finally. A protocol may protect an integration seam, while an interface copied from C# may only add navigation.

Finally, operate the result. Send malformed input, stop a dependency, cancel a request, run concurrent calls and inspect the trace. Build the package outside the source directory. Upgrade one dependency in a branch and run the gates. These exercises reveal understanding that syntax quizzes cannot.

The goal is not to suppress the instincts developed through C#. Keep the valuable ones: explicit contracts, narrow authority, automated tests, observability and respect for production failure. Release the assumptions that every dependency needs a class, every boundary needs inheritance and compile-time checking will protect runtime data. Python rewards clarity, small composable units and directness when those qualities are supported by disciplined engineering.

That balance is the real transition: retain engineering judgement while learning the language’s native tools, conventions and trade-offs. A Python codebase should feel intentionally Pythonic without becoming casual about correctness, security or support.

Build it clearly, test it honestly, and operate it deliberately.

Final mentor summary

Python is not “C# without semicolons.” It has its own philosophy.

C# gives you structure first. Python gives you expression first.

C# asks you to define the shape early. Python lets you explore the shape quickly.

C# is excellent for large enterprise systems where compile-time safety, architecture and tooling matter deeply. Python is excellent where speed of development, data exploration, automation, AI, notebooks and library ecosystem matter deeply.

For an experienced C# developer, the fastest route is this:

Understand Python’s execution model: .py, modules, packages, bytecode, CPython.

Understand names and objects: names point to objects; everything has identity, type and value.

Understand mutability: lists/dicts/sets mutate; strings/tuples/ints do not.

Use type hints: professional Python should communicate intent clearly.

Prefer small functions: not everything needs a class.

Use dataclasses and Pydantic models: they feel natural to a C# record/DTO mindset.

Use comprehensions and generators: Python’s clean answer to many LINQ-style transformations.

Use context managers: Python’s with is your using.

Use pytest: serious Python needs tests.

Use pandas/Jupyter for data: this is where Python becomes the industry language.

Use FastAPI for APIs: type-driven, clean, modern and natural for C# developers.

Use virtual environments and packaging: never treat Python as random scripts in a folder.

The destination is not “I know Python syntax.”

The destination is:

“I can design, write, test, package and run a Python application; I understand how Python behaves at runtime; I can compare it with C# honestly; I can use Python for data science and AI workflows; and I know when Python is the better tool versus when C# remains the better tool.”

That is how you become productive in Python quickly—not by becoming a beginner again, but by translating your existing engineering judgement into a new language.

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View Continuous Learning →

Use this journal entry for recall practice

Compare your explanation with the questions and working answers in my Practice Room.

Practise Python and cross-language engineering questions →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →