Writing

How I learned Python in 2 hours

Jul 2026 · 14 min

Let me set the context first. I wanted to learn Agentic AI, and for that Python is the first priority — JavaScript works too, but Python is the native language for pretty much everything in AI.

So I decided to learn Python. Yeah!

But I already know JavaScript, plus some C++, C#, and a bit of Java — so learning Python as a total beginner doesn't make much sense. It'd also be a waste of time to pick a YouTube playlist and watch each video one by one, right? I wanted to jump straight into building projects as fast as possible — creating agents and, more importantly, understanding the Python code.

That's the important part: understand the Python code. Of course I'm not becoming a Python guru in 2 hours. All I wanted was to understand what's written and how it works.

So I took my classic "First Principles" approach and learned only what matters most.

Goal: understand Python code — not become a guru.

Below are the topics that matter most. I got through them in about 2 hours (with some side research along the way), and here's the crux of it. Last but not least: I used Claude Opus to teach me. Since I come from a JavaScript background, I tried to relate Python concepts back to JS, which made everything click faster.


Installation and environment setup

Install Python from the official website, as usual.

Setting up the environment

The mental model: npm → uv. Almost everything you already know transfers.

JavaScript / npmPython / uv
npm inituv init myproject
package.jsonpyproject.toml
node_modules/.venv/
package-lock.jsonuv.lock
npm install fastapiuv add fastapi
npm install -D pytestuv add --dev pytest
npm install (restore from lock)uv sync
npx ruffuvx ruff
node script.jsuv run python script.py
nvm (node version manager)uv python pin 3.13 (built in — no separate tool)

That last row is a bonus: uv manages Python versions itself, so you don't need a pyenv/nvm equivalent. (You installed Python from the site, which is fine — but uv could've handled that too.)

Step by step, on a fresh machine

1. Install uv (one time, global — like installing Node itself):

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Restart your shell, then confirm: uv --version.

2. Scaffold the project (your npm init):

uv init weather-api
cd weather-api

This creates:

weather-api/
├── .python-version     # pins Python version (like .nvmrc)
├── .gitignore          # already ignores .venv/
├── README.md
├── main.py             # entry point
└── pyproject.toml      # your package.json

3. Pin your Python version (optional but professional):

uv python pin 3.13

4. Add dependencies (your npm install):

uv add fastapi httpx
uv add --dev pytest ruff

The first uv add auto-creates .venv/, writes the packages into pyproject.toml, and generates uv.lock — all in one step. No manual "activate the environment" ritual.

5. Run code (your node script.js):

uv run python main.py

uv run executes inside the project's .venv automatically and syncs first if needed — you never manually activate the venv. This is the big quality-of-life win over the old way.

6. What to commit vs. ignore:

Commit pyproject.toml, uv.lock, and .python-version. Git-ignore .venv/ (it's already in the generated .gitignore). A teammate then clones and runs one command — uv sync — and gets your exact environment.

The old way

python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt

That's the full loop closed: venv (isolation), pip (install), and requirements.txt (the shared list) — so now you know both the modern tool and the primitives underneath it.


The syntax that bites a JS dev immediately

Same mental model, different surface. The big one: indentation replaces braces. Whitespace is the syntax.

JavaScriptPython
let x = 5 / const x = 5x = 5 (no keyword; CONSTANTS are just uppercase by convention)
// comment# comment
`Hi ${name}`f"Hi {name}"
null / undefinedNone
true / falseTrue / False
&& || !and or not
===== (value) — is only for identity/None
arr.lengthlen(arr)
console.log(x)print(x)
a ? b : cb if a else c
{ ... } block: then indented block
i++i += 1 (no ++)

Truthiness is broader than JS: an empty list [], empty dict {}, empty string "", 0, and None are all falsy.

dict

A Python dict is written like a JS object literal but behaves like a JS Map: any hashable key, no prototype baggage. The trap for JS devs: no dot access.

user = {"name": "Ada", "age": 30}

user["name"]              # "Ada"  ← brackets only. user.name FAILS.
user.get("email")         # None   ← safe, no crash on missing key
user.get("email", "-")    # "-"    ← with a default
user["email"] = "a@b.c"   # add or update
"age" in user             # True   ← key membership
del user["age"]           # remove

for k, v in user.items():  # iterate key+value
    print(k, v)

user.name doesn't error the way you'd hope — Python goes looking for an attribute called name and throws AttributeError. Always brackets, or .get() when the key might be missing. .keys(), .values(), and .items() are your Object.keys/values/entries.

Comprehensions — the Pythonic superpower

This is .map/.filter folded into syntax. Once it clicks you'll reach for it constantly.

nums = [1, 2, 3, 4]

[n * 2 for n in nums]              # → [2,4,6,8]      (map)
[n for n in nums if n % 2 == 0]    # → [2,4]          (filter)
{n: n * n for n in nums}           # → {1:1,2:4,...}  (dict comprehension)

JS equivalents: nums.map(n => n*2) and nums.filter(n => n%2===0).

JSON — trivial once dict makes sense

dict ↔ JSON is the natural pairing:

import json

obj = json.loads('{"name": "Ada"}')   # JSON.parse  → dict
text = json.dumps(obj)                 # JSON.stringify → str
json.dumps(obj, indent=2)              # pretty-printed

(One gotcha to just file away for later: never use a mutable value like [] as a function's default argument — it's shared across calls. Bites everyone once.)


Conditionals and loops

if age < 13:
    tier = "child"
elif age < 18:          # else if → elif  (the one that trips JS devs)
    tier = "teen"
else:
    tier = "adult"

elif, not else if. Parens around the condition are optional and usually omitted. Use and/or/not instead of &&/||/!. The ternary you've already used: tier = "adult" if age >= 18 else "child".

Two things JS doesn't have:

if 0 <= age < 18:       # chained comparison — reads like math, no && needed
    ...

if name:                # truthiness: empty str/list/dict, 0, None are all falsy
    ...

match / case — the switch

Python 3.10+ (I'm on 3.14), and it's more powerful than JS switch — it pattern-matches, not just equality:

match command:
    case "start":
        run()
    case "stop" | "halt":       # multiple values, like fallthrough cases
        halt()
    case _:                     # default (the underscore = "anything")
        print("unknown")

No break needed — cases don't fall through the way JS ones do. For simple value checks a plain if/elif chain is still idiomatic; reach for match when branching on shape/structure.

Loops

for is JS's for...of — it iterates values, never indices:

for city in cities:              # for (const city of cities)
    print(city)

for i in range(5):               # 0,1,2,3,4  → your C-style counting loop
    print(i)

range(2, 10, 2)                  # start, stop, step → 2,4,6,8 (stop exclusive)

The two you'll reach for constantly:

for i, city in enumerate(cities):        # index + value (JS: cities.entries())
    print(i, city)

for city, temp in zip(cities, temps):    # walk two lists in parallel — no JS built-in
    print(city, temp)

for key, value in weather.items():       # dict iteration
    print(key, value)

while, break, and continue are identical to JS:

while queue:              # loops while queue is non-empty (truthiness)
    item = queue.pop()
    if item == "skip":
        continue
    if item == "stop":
        break

Two Python-only quirks

for...else — the else runs only if the loop finished without hitting break. Handy for search loops, and there's no JS equivalent:

for user in users:
    if user.name == "Ada":
        print("found")
        break
else:
    print("not found")     # runs only if the loop never broke

No do...while. Emulate it with while True + a break:

while True:
    answer = get_input()
    if answer:            # condition checked at the bottom
        break

Functions

Defining a function is def name(args): — here's everything a JS dev needs to know about the parts that differ.

Default and keyword arguments

def greet(name, greeting="Hi"):      # default arg, like JS default params
    return f"{greeting}, {name}"

greet("Ada")                 # "Hi, Ada"
greet("Ada", "Yo")           # positional
greet("Ada", greeting="Yo")  # keyword argument
greet(greeting="Yo", name="Ada")   # order doesn't matter when named

Keyword arguments are a big deal in Python — you pass args by name, in any order. JS fakes this with an options object ({ name, greeting }); Python has it natively, and libraries like FastAPI lean on it heavily.

*args / **kwargs are the rest/spread cousins:

def f(*args, **kwargs):
    print(args)      # tuple of extra positional args
    print(kwargs)    # dict of extra keyword args

f(1, 2, x=3)         # args=(1, 2)   kwargs={'x': 3}

*args ≈ JS rest ...args. **kwargs collects extra named args into a dict — no clean JS equivalent.

Type hints — the one that matters most for later

def greet(name: str, greeting: str = "Hi") -> str:
    return f"{greeting}, {name}"

If you've touched TypeScript, this is the same idea: name: str, return type after ->. The crucial difference from TS: Python does not enforce these at runtime — they're annotations. Your editor reads them, type checkers (mypy/ty) read them, and — the reason this matters so much — Pydantic and FastAPI read them to validate data automatically.

The common ones, with TS equivalents:

name: str            # string
age: int             # number (integer)
price: float         # number (decimal)
active: bool         # boolean
tags: list[str]      # string[]
user: dict[str, int] # object with str keys → int values
maybe: str | None    # string | null

Python 3.10+ uses | for unions exactly like TS, and I'm on 3.14, so all of this works.

The if __name__ == "__main__": guard

You'll see this at the bottom of almost every Python file, so let me explain it. __name__ is a built-in variable Python sets automatically. When you run a file directly (python file.py) it equals "__main__"; when the file is imported by another file, it equals the module's name instead. So the block means: run main() only when this file is the entry point, not when someone imports it. The JS bridge: it's Node's if (require.main === module), or import.meta.main in Bun/Deno.

def main():
    ...

if __name__ == "__main__":
    main()

Classes

Plain classes

The JS class you know, with three surface changes:

class User:
    def __init__(self, name: str, age: int):   # constructor
        self.name = name                        # this.name = name
        self.age = age

    def greet(self) -> str:
        return f"Hi, {self.name}"

u = User("Ada", 30)     # no `new` keyword
u.greet()               # "Hi, Ada"

Three things to internalize coming from JS:

  • constructor__init__ (a "dunder" = double-underscore method; Python calls these automatically).
  • thisself, and here's the one that catches JS devs: self is an explicit first parameter on every method. You write def greet(self) but call u.greet() — Python passes the instance in for you.
  • No new. You call the class like a function: User("Ada", 30).

Dataclasses — the part you'll actually use

Notice the plain class made you type each field name three times (param, self.x, = x). A dataclass generates all that boilerplate from the type declarations:

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

u = User("Ada", 30)
print(u)                 # User(name='Ada', age=30)   ← __repr__ for free
u == User("Ada", 30)     # True                        ← value equality for free

You declare the fields with types and Python builds __init__, __repr__, and __eq__ for you. If you've written TypeScript classes with parameter properties, this is that same "declare fields, get a constructor" feeling.

That @dataclass on top is a decorator — a function that wraps the class below it and returns an enhanced version (@dataclass is literally User = dataclass(User)). If you've touched NestJS or Angular (@Injectable(), @Component()), it's the exact same idea. Worth getting comfortable with now, because FastAPI defines routes with decorators (@app.get(...)) — you'll see this in the FastAPI section.

Defaults and methods work as you'd expect:

@dataclass
class User:
    name: str
    age: int = 0                    # default value

    def is_adult(self) -> bool:     # methods live right in the class
        return self.age >= 18

Why this is the linchpin: Pydantic's models (coming up in the FastAPI section) look almost identical to a dataclass — same field-with-type syntax — but add automatic runtime validation. So once dataclasses click, Pydantic is "a dataclass that also checks its data." You're building straight toward the API.

Inheritance ("is-a")

Same concept as JS extends, with three syntax changes:

@dataclass
class User:
    name: str
    age: int

    def is_adult(self) -> bool:
        return self.age >= 18

    def describe(self) -> str:
        return f"{self.name}, age {self.age}"


@dataclass
class Admin(User):              # Admin extends User → class Admin(User)
    role: str = "admin"         # adds a new field on top of User's

    def describe(self) -> str:              # override the parent method
        base = super().describe()           # call User's version
        return f"{base} [{self.role}]"


a = Admin("Ada", 30, "superadmin")
a.is_adult()          # True   ← inherited method, for free
a.describe()          # "Ada, age 30 [superadmin]"
isinstance(a, User)   # True   ← an Admin is also a User

The bridges:

  • class Admin extends Userclass Admin(User) (parentheses, no extends keyword).
  • super.describe()super().describe() — note the parentheses on super(); it returns a proxy to the parent.
  • a instanceof Userisinstance(a, User).

With a dataclass, the constructor is auto-generated to take inherited fields first, then new ones — so Admin(name, age, role). You don't write super().__init__() yourself. But in a plain class you do, exactly like JS:

class Admin(User):
    def __init__(self, name, age, role):
        super().__init__(name, age)   # run User's constructor (JS: super(name, age))
        self.role = role

Why it matters: a Pydantic model is just a class that inherits from BaseModel. So class User(BaseModel): is this exact pattern — you already know it now.

Composition ("has-a")

Instead of being a kind of something, an object holds other objects as fields:

@dataclass
class Address:
    city: str
    country: str

@dataclass
class User:
    name: str
    age: int
    address: Address        # a User HAS-A Address

u = User("Ada", 30, Address("Lucknow", "India"))
u.address.city              # "Lucknow"  ← reach into the nested object

Admin is-a User (inheritance); User has-a Address (composition). The rule devs repeat — favor composition over inheritance — is because deep inheritance trees get rigid, while composing small objects stays flexible.


Async / await, asyncio

Core syntax + the lazy-coroutine gotcha

import asyncio

async def hello() -> str:      # async function f()  → async def f():
    return "hi"

hello()               # <coroutine object hello at 0x...>  ← did NOT run!

That's the break. In JS, calling hello() immediately starts the function and hands you a Promise that's already executing. In Python, calling an async def runs nothing — it just builds a coroutine object that sits there inert until something drives it. Two ways to actually run it:

await hello()          # "hi"  — but only works *inside* another async function
asyncio.run(hello())   # "hi"  — from normal top-level code; starts the event loop

asyncio.run(...) is the piece with no JS equivalent: it boots the event loop, runs your coroutine to completion, and tears the loop down. In JS the loop is always there in the background; in Python you start it explicitly, and you call asyncio.run() once, at your program's entry point.

async def main():
    result = await hello()
    print(result)

asyncio.run(main())     # the one entry point

await inside main works the same as JS. asyncio.sleep(1) is your await new Promise(r => setTimeout(r, 1000)) — but note the unit is seconds, not milliseconds.

Why async is worth it: concurrency

Here's the payoff, and the thing that's genuinely hard to see from code because the two versions look almost identical:

# Sequential — await one, then the next. Total ≈ 3s.
a = await fetch(url1)
b = await fetch(url2)
c = await fetch(url3)

# Concurrent — all three in flight at once. Total ≈ 1s.
a, b, c = await asyncio.gather(fetch(url1), fetch(url2), fetch(url3))

asyncio.gather(...) is your Promise.all([...]) — with one difference: you pass the awaitables as separate arguments, not as a single list. The two snippets look nearly identical, but in time they're very different: the sequential version waits for each call to finish before starting the next, while gather fires all three at once. That's the whole reason to bother with async — same three calls, 3× faster when they're I/O-bound (network requests). The catch: this only works if the thing you're awaiting is actually async. A normal blocking call inside an async def still freezes everything — and that's where httpx comes in.


httpx and FastAPI

You already installed httpx (uv add httpx). It has both a sync and an async client; the async one is what pairs with everything above. Here's a real call against a keyless weather API — the exact shape our little weather app needs:

import asyncio
import httpx

async def get_temp(lat: float, lon: float) -> float:
    url = "https://api.open-meteo.com/v1/forecast"
    params = {"latitude": lat, "longitude": lon, "current": "temperature_2m"}
    async with httpx.AsyncClient() as client:
        resp = await client.get(url, params=params)   # like: await fetch(url)
        data = resp.json()                             # like: await resp.json()  (but no await here)
        return data["current"]["temperature_2m"]

async def main():
    temp = await get_temp(26.85, 80.95)   # Lucknow
    print(f"Current temp: {temp}°C")

asyncio.run(main())

Three things to notice against JS fetch:

  • async with httpx.AsyncClient() as client: — this is a context manager. It opens the client and guarantees it closes when the block ends, even on error — like a built-in try/finally. The async version is needed because closing involves awaiting. Rough JS analogue: await using client = ..., or a manual try { ... } finally { await client.aclose() }.
  • resp.json() has no await — in httpx the body is already downloaded by the time await client.get() returns, so parsing is synchronous. (In JS, resp.json() is itself async. Small but easy to trip on.)
  • params={...} builds the query string for you (?latitude=...&longitude=...) — same idea as axios's params.

FastAPI

First, add the server (you have fastapi and httpx, but need the thing that actually runs it):

uv add "uvicorn[standard]"

The mental model: FastAPI ≈ Express, but the types do real work

In Express you write app.get("/", (req, res) => res.json(...)) and validate the body by hand. FastAPI reads your type hints to route, parse, validate, and even document — automatically. The annotations you thought were just editor hints now enforce things at runtime.

A minimal app:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "hello"}     # a dict → auto-serialized to JSON, no res.json()

Bridges: app = FastAPI() is app = express(). @app.get("/") is your app.get("/", handler) — the decorator is the routing (this is exactly the decorator idea from the dataclass section). And returning a dict auto-converts to a JSON response; you never touch a res object.

Run it:

uv run uvicorn main:app --reload

main:app means "the app variable inside main.py" (module:variable). --reload is nodemon. Now open http://127.0.0.1:8000/docs — FastAPI generated interactive API documentation from your code. You can fire requests at your endpoints right there in the browser, no Postman or curl. Express gives you nothing like this for free; it's the feature that sells people.

Pydantic models

A Pydantic model looks just like your dataclass, but it validates:

from pydantic import BaseModel

class WeatherRequest(BaseModel):     # inherits BaseModel — the inheritance you learned
    latitude: float
    longitude: float

It's "a dataclass that checks its data." Feed it a string where a float is expected and it raises a validation error instead of silently continuing.

The POST endpoint — your get_temp becomes an API

Now the convergence. You type-hint the request parameter as your model, and FastAPI parses the incoming JSON body into it, validated:

@app.post("/weather")
async def get_weather(req: WeatherRequest):
    temp = await get_temp(req.latitude, req.longitude)   # your async httpx code
    return {"temperature": temp}

Everything you built is doing a job here:

  • req: WeatherRequest — FastAPI reads the type hint, parses the POST body's JSON into a WeatherRequest, and auto-returns a 422 error if latitude is missing or not a number. You wrote zero validation code.
  • req.latitude — dot access, because req is a model object, not a dict.
  • async def + await get_temp(...) — your async code from earlier runs unchanged inside the handler. FastAPI is async-native; it awaits your coroutine on its event loop, so one slow weather call doesn't block other incoming requests.

Data structures

Python's built-in data structures split cleanly into mutable (can be changed in place) and immutable (can't):

TypeJS bridgeMutable?
listArray✅ mutable
dictMap / object✅ mutable
setSet✅ mutable
bytearrayUint8Array✅ mutable
tuplereadonly array❌ immutable
frozensetreadonly Set❌ immutable
strstring❌ immutable
int float boolnumber / boolean❌ immutable
Nonenull❌ immutable

The split isn't new to you — JS has the same idea, just with fewer types: primitives (number, string, boolean) are immutable, objects/arrays are mutable. Python just has more members on each side.

Quick tour of the containers

list — ordered, mutable, allows duplicates. Your Array. append/pop/insert/remove, slicing, comprehensions.

tuple — ordered but immutable and fixed-size. (1, 2, 3). No real JS equivalent (closest is a TS readonly tuple). Three real uses:

point = (26.85, 80.95)        # a fixed record
lat, lon = point              # unpacking (destructuring)
def bounds(): return lo, hi   # returning multiple values → a tuple

dict — key→value, mutable. Keys must be hashable (more on that below). Your Map.

set — unordered collection of unique values, mutable. Fast membership tests, dedup, and set algebra:

a = {1, 2, 3}
b = {3, 4}
a | b      # union → {1,2,3,4}
a & b      # intersection → {3}
3 in a     # membership, very fast

One gotcha: {} is an empty dict, not a set. An empty set is set().

frozenset — an immutable set. Matters only because it can be a dict key or live inside another set (a normal set can't).

str — an immutable sequence of characters. Any "mutation" returns a new string — identical to JS, where s.toUpperCase() doesn't change s.

Scalars (int, float, bool, None) — immutable, like JS primitives.

The concept: what mutability actually does

An immutable object can never change — "modifying" it builds a new object. A mutable object can change in place: same object, new contents. Python gives every object an identity you can see with id(x) (think: its memory address / reference identity).

The place this bites: assignment copies the reference, not the object.

a = [1, 2, 3]
b = a           # b points at the SAME list, no copy made
b.append(4)
print(a)        # [1, 2, 3, 4]  ← a changed too!
a is b          # True  ← same object

That's aliasing, and you already know it from JS (const b = a on an array does the same). The reason it feels sharper in Python is that immutable types quietly don't behave this way:

x = 5
y = x
x += 1          # int is immutable → this builds a NEW int and rebinds x
print(y)        # 5  ← y untouched

With the list, +=/append mutated the shared object. With the int, += couldn't mutate (ints are immutable), so it made a new object and pointed x at it — leaving y alone. Same operator, opposite outcome, entirely because of mutability.

is vs ==: == compares values, is compares identity (same object). For your own data always use ==; reserve is for None, which is the idiomatic check you'll see everywhere:

if result is None:      # not `== None`
    ...

Copying — when you actually want an independent list:

b = a.copy()      # or a[:]  → a fresh list, separate object (shallow)
import copy
b = copy.deepcopy(a)   # also copies nested objects inside

A shallow copy duplicates the outer container but still shares the nested objects inside it; deepcopy goes all the way down.

Why the split earns its keep

Two concrete payoffs, both things you've already brushed against:

1. Only immutable things can be dict keys or set members. Keys must be hashable, and mutable objects aren't. This is the entire reason tuple exists as a separate type from list:

locations = {(26.85, 80.95): "Lucknow"}   # tuple key ✓
locations[(26.85, 80.95)]                  # "Lucknow"
{[26.85, 80.95]: "Lucknow"}                # TypeError: unhashable type: 'list'

2. The mutable-default footgun, finally explained. Remember I said never use [] as a default argument? Here's why:

def add(item, items=[]):      # ⚠️ the [] is created ONCE, at definition time
    items.append(item)
    return items

add("a")    # ['a']
add("b")    # ['a', 'b']  ← the SAME list persisted across calls!

The default list is one mutable object, built when the function is defined and shared by every call. The fix uses an immutable sentinel:

def add(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

Yeah! That's all you need to understand pretty much any Python codebase — and you can always fall back to the official docs when needed. Two libraries worth digging into more:

  • Pydantic
  • FastAPI