June 22, 2026
Learning Python - From a Frontend-Heavy JS Backend Dev to Python: My First 3 Days
I’m a frontend-heavy JS developer. Most of my hours go into React and the UI layer, and the backend work I have done has been with HonoJS and Express, useful, but never deep. I know enough to wire up routes and call it done, not enough to say I actually understand backend development. So I wanted to get into development with Python and use it as the language I actually go deep on the backend side with, instead of treating backend as the thing I do just enough of to ship a frontend feature. The long-term goal is FastAPI, but I didn’t want to skip straight to the framework and end up not understanding what it was doing underneath. So I started from the actual language.
I’m learning this code-first, one segment a day, working through small examples with an AI tutor instead of reading a textbook front to back. These are my notes from the first three days, including the mistakes.
Day 1 — Python’s “different” things
Coming from JS, the syntax is where it hits you first. There’s no curly braces holding a block together, no semicolons closing a line, and self has to be written out by hand everywhere instead of being implicit like this. Most of it felt familiar once I sat with it, but a few habits needed unlearning fast.
Indentation instead of braces
def greet(name: str) -> str:
if name:
return f"Hello, {name}"
return "Hello, stranger"
The indentation is the block here. Coming from JS, where whitespace is mostly cosmetic and you could write everything on one line if you wanted to be obnoxious about it, trusting that the indentation alone defines scope took a minute to sink in.
Lists, dicts, tuples
skills = ["python", "fastapi", "sql"] # list (mutable)
config = {"host": "localhost", "port": 8000} # dict
point = (10, 20) # tuple (immutable)
upper_skills = [s.upper() for s in skills]
# -> ["PYTHON", "FASTAPI", "SQL"]
port_map = {name: i for i, name in enumerate(skills)}
# -> {"python": 0, "fastapi": 1, "sql": 2}
This is where I was genuinely surprised at how little code it takes to do things I’d normally write as a .map() and .filter() chain in JS. A comprehension collapses that into one line that still reads clearly.
*args and **kwargs
def log(message: str, *tags, **meta):
print(f"[{', '.join(tags)}] {message} | {meta}")
log("Server started", "info", "startup", host="localhost", port=8000)
# -> [info, startup] Server started | {'host': 'localhost', 'port': 8000}
This maps pretty closely to JS rest params plus an options object, just folded into one calling convention instead of two separate patterns.
Classes, simplified
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def __repr__(self):
return f"User({self.name})"
def to_dict(self) -> dict:
return {"name": self.name, "email": self.email}
Having to write self explicitly on every method felt verbose at first, since this is just there for free in a JS class. It grew on me once I realized it makes it obvious at a glance which variables belong to the instance and which don’t.
Error handling
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
try:
result = divide(10, 0)
except ValueError as e:
print(f"Error: {e}")
finally:
print("Always runs")
None is Python’s null
def find_user(id: int) -> User | None:
if id == 1:
return User("Dakshina", "d@example.com")
return None
found = find_user(99)
if found is None: # use `is None`, never `== None`
print("Not found")
JS vs Python, side by side
| JS | Python |
|---|---|
null / undefined | None |
NaN (silent failure) | raises ValueError (explicit) |
[] is truthy | [] is falsy |
typeof x | type(x) |
| template literals | f-strings |
array.length | len(array) |
The [] being falsy is the one that actually got me. In JS, an empty array inside an if is truthy, and I’d built a habit of relying on that without thinking about it. Python flips it, and I didn’t catch the difference until a function quietly skipped a branch I expected to run.
Mistakes I actually made on day 1
# wrong type name
def foo(name: string) # 'string' doesn't exist -> use str
# not a method on lists
tech_stack.len() # -> use len(tech_stack)
# return instead of raise
return ValueError("...") # should be raise, not return
# dict keys need quotes
{name: name} # -> {"name": name}
The return ValueError(...) mistake is the sneakiest of the four, because Python doesn’t complain about it at all. It just quietly hands back an exception object instead of throwing it, and the caller has no idea anything went wrong until something downstream breaks for a reason that makes no sense.
Day 2 — Data structures and comprehensions
This is the day Python started feeling genuinely expressive rather than just different for the sake of it. Comprehensions in particular replace a lot of the manual loop-and-push code I’d normally write in JS.
Lists
stack = ["python", "fastapi", "postgresql"]
stack.append("redis")
stack.insert(1, "pydantic")
stack.remove("redis")
popped = stack.pop()
print("fastapi" in stack) # True, clean membership check
for i, tech in enumerate(stack):
print(f"{i}: {tech}")
Using in for a membership check is something I genuinely wish JS had this clean. .includes() does the job but it reads clunkier every time I have to type it.
Dicts
user = {"id": 1, "name": "Dakshina", "email": "d@example.com"}
print(user.get("missing")) # None, no crash
print(user.get("missing", "default")) # "default"
for key, value in user.items():
print(f"{key}: {value}")
defaults = {"role": "user", "is_active": True}
overrides = {"role": "admin"}
merged = defaults | overrides # dict merge, Python 3.9+
Having .get() come with a built-in fallback value is a small thing on paper, but it quietly removes a whole category of undefined-checking I’d otherwise be writing by hand.
Tuples and sets
coordinates = (6.9271, 79.8612)
lat, lng = coordinates # unpacking
tags = {"python", "api", "python"} # duplicates auto-removed
# -> {"python", "api"}
Comprehensions, the highlight of the day
users = [
{"name": "Alice", "role": "admin", "is_active": True},
{"name": "Bob", "role": "user", "is_active": True},
{"name": "Carol", "role": "admin", "is_active": False},
{"name": "Dave", "role": "admin", "is_active": True},
]
def filter_users(users: list, role: str) -> list:
result = [u["name"] for u in users if u["role"] == role and u["is_active"]]
if len(result) == 0:
raise ValueError(f"No active users found with role: {role}")
return result
print(filter_users(users, "admin")) # -> ["Alice", "Dave"]
# dict comprehension
name_to_role = {u["name"]: u["role"] for u in users}
# -> {"Alice": "admin", "Bob": "user", ...}
A single line here replaces what would be a .filter().map() chain in JS, and it reads almost like a plain sentence once it clicks: “give me the name for each user where the role matches and they’re active.”
The mental shift that mattered most today
# try/except = handle errors that MIGHT happen
try:
return int(port)
except ValueError:
return 3000
# if/raise = enforce YOUR OWN business rules
if len(result) == 0:
raise ValueError("...")
try/except is for things genuinely outside my control, like bad input or a conversion that might fail. if/raise is for rules I’m choosing to enforce myself. I’d been treating both the same way in JS, wrapping everything in try/catch out of habit, and separating the two made my Python code a lot more honest about what it was actually protecting against.
Day 3 — OOP, the part that actually matters for APIs
This was the day that mattered most for backend work, because Pydantic models and database models in FastAPI all end up being classes built on exactly these patterns. Getting comfortable with this now is going to pay off later.
Coming from the JS world, this part felt the strangest of the three days, but it ended up being the most fun to work through. It took some real picking apart in my head to get comfortable with, though it came together quickly once I had enough examples and a clear path from one to the next.
Basic class and constructor
class User:
def __init__(self, name: str, email: str, role: str = "user"):
self.name = name
self.email = email
self.role = role
self.is_active = True
Instance methods and method chaining
class User:
def deactivate(self):
self.is_active = False
def promote(self, new_role: str):
self.role = new_role
return self # enables chaining
user.promote("admin").deactivate()
__repr__ and to_dict(), the API developer’s best friend
class User:
def __repr__(self) -> str:
return f"User(name={self.name}, role={self.role})"
def to_dict(self) -> dict:
return {
"name": self.name,
"email": self.email,
"role": self.role,
"is_active": self.is_active
}
Class attributes, @classmethod, @staticmethod
class User:
allowed_roles = ["user", "admin", "superadmin"]
user_count = 0
def __init__(self, name: str, email: str, role: str = "user"):
if role not in User.allowed_roles:
raise ValueError(f"Invalid role: {role}")
self.name = name
self.email = email
self.role = role
User.user_count += 1
@classmethod
def create_admin(cls, name: str, email: str) -> "User":
return cls(name, email, role="admin")
@staticmethod
def is_valid_email(email: str) -> bool:
return "@" in email and "." in email
admin = User.create_admin("Dakshina", "d@example.com")
print(User.is_valid_email("d@example.com")) # True
self | cls | use case | |
|---|---|---|---|
| instance method | yes | no | work with instance data |
@classmethod | no | yes | factory, alt constructors |
@staticmethod | no | no | utility tied to the class |
Inheritance with super()
class Base:
def __init__(self, id: str):
self.id = id
def to_dict(self):
return {"id": self.id}
class User(Base):
def __init__(self, id: str, name: str, age: int):
super().__init__(id)
self.name = name
self.age = age
def to_dict(self):
base = super().to_dict()
base.update({
"name": self.name,
"age": self.age
})
return base
Enums for fixed option sets
from enum import Enum
class Color(Enum):
BROWN = "brown"
BLACK = "black"
WHITE = "white"
class Animal:
def __init__(self, name: str, color: Color = Color.BROWN):
self.name = name
self.color = color
def to_dict(self) -> dict:
return {"name": self.name, "color": self.color.value}
One thing worth flagging here is that Python’s type hints, like color: Color, aren’t actually enforced at runtime. You can still pass the wrong type in and Python won’t stop you. That surprised me a little, since the hints look like they should be doing real validation. It turns out that’s deliberately setting up for later, when I get to Pydantic, which does enforce types automatically at the API boundary.
The capstone exercise
class Project:
def __init__(self, name: str, tech_stack: list, owner: str, is_live: bool = False):
if len(tech_stack) == 0:
raise ValueError("Tech stack should not be empty")
self.name = name
self.tech_stack = tech_stack
self.owner = owner
self.is_live = is_live
def __repr__(self):
return f"This is some awesome project {self.name}!"
def add_tech(self, tech: str):
if tech not in self.tech_stack:
self.tech_stack.append(tech)
def deploy(self):
self.is_live = True
return self
def to_dict(self):
return {
"name": self.name,
"tech_stack": self.tech_stack,
"owner": self.owner,
"is_live": self.is_live
}
@classmethod
def from_dict(cls, data: dict) -> "Project":
return cls(data["name"], data["tech_stack"], data["owner"])
p = Project("Study Abroad VN", ["Astro", "Tailwind"], owner="Dakshina")
p.add_tech("Cloudflare")
p.add_tech("Astro") # no duplicate added
p.deploy()
print(p)
print(p.to_dict())
p2 = Project.from_dict({
"name": "Test Project",
"tech_stack": ["FastAPI"],
"owner": "Alice"
})
print(p2.to_dict())
The bug that actually cost me time on this one was small and dumb in hindsight. Inside from_dict, I wrote dict["name"] instead of data["name"]. Python didn’t throw anything obviously useful, it just got confused, because dict is also the name of Python’s built-in dictionary type, and I’d shadowed it without even noticing I’d done it. The lesson landed immediately: never name a variable after a built-in like dict, list, id, or type.
# shadows built-ins, avoid this
dict = {"name": "Alice"}
list = [1, 2, 3]
id = 123
# use descriptive names instead
data = {"name": "Alice"}
items = [1, 2, 3]
user_id = 123
What three days in actually feels like
What surprised me most coming from JS is how often Python rewards being explicit. Writing self everywhere, using is None instead of == None, choosing raise over return, type hints that look enforced but quietly aren’t, it all adds up to a language that keeps nudging you to spell things out rather than let them slide.
OOP in Python ended up feeling more deliberate than JS classes ever did for me. Decorators like @classmethod and @staticmethod give you a vocabulary for why a method exists, not just what it does, and that’s something JS classes don’t really have a clean equivalent for.
Next up is modules and JSON handling, and then straight into FastAPI, which is the actual reason I’m doing any of this. If you’re coming from a JS backend background and you’re curious how the framework side compares, follow along for the rest of the series.