Classes and Instances — Defining Custom Types

A class is a blueprint for creating objects — it defines the data an object holds (attributes) and the behaviours it has (methods). Python’s object-oriented system is the foundation of everything in FastAPI: Pydantic’s BaseModel, SQLAlchemy’s Base, FastAPI’s HTTPException, and every database model you write are all classes. Understanding how to define classes, create instances, write methods, and use self correctly is the most important step up from procedural Python and the gateway to writing real web application code.

Defining a Class

# class keyword + PascalCase name (convention)
class Post:
    # __init__ is the constructor — called when creating an instance
    # 'self' is always the first parameter — refers to the instance being created
    def __init__(self, title: str, body: str, published: bool = False):
        # Instance attributes — stored on the object
        self.title     = title
        self.body      = body
        self.published = published
        self.view_count = 0          # default attribute not from parameter

    # Instance method — first parameter is always self
    def publish(self):
        self.published = True
        print(f"'{self.title}' is now published.")

    def increment_views(self):
        self.view_count += 1

    def summary(self) -> str:
        status = "published" if self.published else "draft"
        return f"{self.title} ({status}) — {self.view_count} views"

# Create instances — call the class like a function
post1 = Post("Hello World", "My first post body")
post2 = Post("FastAPI Guide", "FastAPI tutorial body", published=True)

# Access attributes
print(post1.title)       # Hello World
print(post1.published)   # False
print(post2.published)   # True

# Call methods
post1.publish()                   # 'Hello World' is now published.
post1.increment_views()
post1.increment_views()
print(post1.summary())            # Hello World (published) — 2 views
Note: self is a convention, not a keyword — Python does not enforce the name self. However, using anything other than self is considered extremely bad practice and will confuse every Python developer who reads your code. The reason self must be the first parameter of every instance method is that Python automatically passes the calling instance as the first argument: post1.publish() is equivalent to Post.publish(post1).
Tip: Every attribute your class needs should be initialised in __init__, even if the initial value is None or a default. This makes the full structure of the class visible at a glance and prevents AttributeError from accessing attributes that were never set. Tools like mypy and IDE autocompletion rely on attributes being declared in __init__ to provide useful type checking and suggestions.
Warning: Class-level attributes (defined directly under the class, outside any method) are shared across all instances. If you define tags = [] at the class level and one instance modifies it, all instances see the change. Always define mutable default attributes inside __init__ using self.tags = [], not at the class level. Read-only constants and class-wide configuration values are appropriate as class attributes.

Class Attributes vs Instance Attributes

class Post:
    # Class attribute — shared by ALL instances
    post_count = 0
    ALLOWED_STATUSES = {"draft", "published", "archived"}

    def __init__(self, title: str):
        # Instance attributes — unique to each instance
        self.title  = title
        self.status = "draft"
        Post.post_count += 1   # increment the class counter

    @classmethod
    def get_count(cls):
        return cls.post_count

p1 = Post("First")
p2 = Post("Second")
print(Post.post_count)     # 2
print(Post.get_count())    # 2
print(p1.post_count)       # 2 — instance reads class attribute
print(p1.title)            # "First" — instance attribute
print(p2.title)            # "Second" — different for each instance

# Danger: mutating a mutable class attribute via instance
class Bad:
    tags = []   # class-level list — shared!

b1 = Bad()
b2 = Bad()
b1.tags.append("python")
print(b2.tags)   # ["python"] — b2's tags was also changed!

class Good:
    def __init__(self):
        self.tags = []   # instance-level list — independent ✓

Instance Methods in Depth

class User:
    def __init__(self, name: str, email: str, role: str = "user"):
        self.name   = name
        self.email  = email.lower().strip()
        self.role   = role
        self._password_hash = None   # _ prefix = "private by convention"

    def set_password(self, password: str) -> None:
        import hashlib
        self._password_hash = hashlib.sha256(password.encode()).hexdigest()

    def check_password(self, password: str) -> bool:
        import hashlib
        hashed = hashlib.sha256(password.encode()).hexdigest()
        return self._password_hash == hashed

    def is_admin(self) -> bool:
        return self.role == "admin"

    def promote(self, new_role: str) -> None:
        allowed = {"user", "editor", "admin"}
        if new_role not in allowed:
            raise ValueError(f"Invalid role: {new_role}")
        self.role = new_role
        print(f"{self.name} promoted to {new_role}")

    def to_dict(self) -> dict:
        """Return a serialisable representation (no password)."""
        return {"name": self.name, "email": self.email, "role": self.role}

# Using the class
alice = User("Alice Smith", " ALICE@EXAMPLE.COM ", "admin")
print(alice.email)         # alice@example.com (normalised in __init__)
print(alice.is_admin())    # True

alice.set_password("secret123")
print(alice.check_password("secret123"))  # True
print(alice.check_password("wrong"))      # False

print(alice.to_dict())
# {"name": "Alice Smith", "email": "alice@example.com", "role": "admin"}

Python OOP vs JavaScript OOP

Concept Python JavaScript
Define class class MyClass: class MyClass {}
Constructor def __init__(self, ...): constructor(...) {}
Instance reference self (explicit param) this (implicit)
Private attribute self._attr (convention) #attr (enforced)
Class attribute MyClass.attr = x static attr = x
Inheritance class Child(Parent): class Child extends Parent {}
Call parent super().__init__(...) super(...)
Instantiate obj = MyClass() obj = new MyClass()
Type check isinstance(obj, MyClass) obj instanceof MyClass

Common Mistakes

Mistake 1 — Forgetting self in method definitions

❌ Wrong — missing self parameter:

class Post:
    def publish():   # missing self!
        self.published = True   # NameError: name 'self' is not defined

✅ Correct — always include self as first parameter:

class Post:
    def publish(self):   # ✓
        self.published = True

Mistake 2 — Mutable class-level default

❌ Wrong — class-level list shared across instances:

class Post:
    tags = []   # shared by all Post instances!

✅ Correct — initialise in __init__:

class Post:
    def __init__(self):
        self.tags = []   # each instance gets its own list ✓

Mistake 3 — Calling a method without an instance

❌ Wrong — calling instance method on the class:

Post.publish()   # TypeError: publish() missing 1 required argument: 'self'

✅ Correct — call on an instance:

post = Post("Title", "Body")
post.publish()   # ✓ Python passes post as self automatically

Quick Reference

Task Code
Define class class MyClass:
Constructor def __init__(self, params): self.attr = val
Instance method def method(self): ...
Create instance obj = MyClass(args)
Access attribute obj.attr
Call method obj.method()
Type check isinstance(obj, MyClass)
Class attribute MyClass.count = 0 (outside methods)
Private convention self._private_attr

🧠 Test Yourself

What is printed by the following code?
class Counter:
    count = 0
    def __init__(self):
        Counter.count += 1
a = Counter()
b = Counter()
print(a.count, b.count, Counter.count)