What Are Attributes in Python?

Attributes are named values attached to Python objects. They can store data (like variables) or behavior (like methods). Every time you use dot notation to access a value on an object — like my_dog.name or math.pi — you are working with an attribute. This guide explains how Python attributes work, including class attributes, instance attributes, attribute lookup order, and built-in functions like getattr(), setattr(), and hasattr().

In Python, everything is an object. Strings, integers, lists, functions, and even classes themselves are all objects. And every object can hold named pieces of data or behavior attached to it. Those named pieces are called attributes. This article walks through the different kinds of attributes, how to use them, and the built-in tools Python provides for managing them.

What Is an Attribute?

An attribute is a value associated with an object that you access using dot notation. When you write object.name, you are asking Python to look up the attribute called name on that object and return its value.

Attributes come in two general flavors: data attributes, which store values like strings, numbers, or lists, and method attributes, which are functions defined inside a class. Both are accessed the same way, using a dot followed by the attribute name.

# A simple example using a string object
greeting = "Hello, Python!"

# .upper is a method attribute
print(greeting.upper())  # HELLO, PYTHON!

# Modules have attributes too
import math
print(math.pi)  # 3.141592653589793

In the first example, upper is a method attribute that belongs to every string object. In the second, pi is a data attribute that belongs to the math module. Both are accessed with the same dot notation syntax.

Why This Matters

The dot is doing more work than it appears. It is triggering a lookup process — one that searches through namespaces in a specific order. That lookup is the invisible engine behind everything in this article. If you understand it, the sections ahead on class vs. instance attributes, name mangling, and @property will all click into place as different ways of influencing the same mechanism.

Note

In Python's official documentation, the word "attribute" refers to any name that follows a dot. So in the expression z.real, the name real is an attribute of the object z. This definition applies whether the object is an instance, a class, a module, or anything else.

Class Attributes vs. Instance Attributes

When you start writing your own classes, you will quickly run into two distinct kinds of attributes: class attributes and instance attributes. The difference between them is where the data lives and how it is shared.

Class Attributes

A class attribute is defined directly inside the class body, outside of any method. It belongs to the class itself and is shared by every instance of that class. If you change a class attribute, the change is visible to all instances (unless an instance has overridden that attribute with its own value).

class Dog:
    # Class attribute - shared by all Dog instances
    species = "Canis familiaris"

    def __init__(self, name, age):
        # Instance attributes - unique to each Dog
        self.name = name
        self.age = age

buddy = Dog("Buddy", 9)
miles = Dog("Miles", 4)

# Both instances share the class attribute
print(buddy.species)  # Canis familiaris
print(miles.species)   # Canis familiaris

# But each has its own instance attributes
print(buddy.name)  # Buddy
print(miles.name)   # Miles

Instance Attributes

An instance attribute is created on a specific object, usually inside the __init__ method by assigning to self.attribute_name. Each instance carries its own copy of these attributes, so changing one instance's data has no effect on another instance.

# Changing an instance attribute affects only that instance
buddy.age = 10
print(buddy.age)  # 10
print(miles.age)   # 4 (unchanged)
Common Misconception
Wrong mental model
"When I write buddy.species, Python copies the class attribute into the instance." It does not copy anything. Python simply walks up the chain: it checks the instance first, and when it does not find species there, it checks the class.
Correct mental model
Class attributes are like a shared bulletin board. Every instance can read from it. But the moment you assign buddy.species = "House cat", you are pinning a personal note to buddy that now shadows the bulletin board. Other instances still see the original.
Pro Tip

When Python looks up an attribute on an instance, it first checks the instance's own namespace. If the attribute is not found there, Python then checks the class. This lookup order is why an instance can "shadow" a class attribute by defining its own attribute with the same name.

POP QUIZ
What does this code print?
class Cat: species = "Felis catus" def __init__(self, name): self.name = name whiskers = Cat("Whiskers") shadow = Cat("Shadow") whiskers.species = "House cat" print(shadow.species)
Pick one:
Correct
Assigning whiskers.species = "House cat" creates a new instance attribute on whiskers only. It does not change the class attribute. Since shadow has no instance attribute called species, Python falls back to the class and finds the original value: "Felis catus". This is the lookup chain at work — the same mechanism the interactive simulator below lets you trace step by step.
Not quite
That would be true if class attributes worked like shared mutable state for simple assignment, but they do not. The line whiskers.species = "House cat" creates a new instance attribute on whiskers rather than modifying the class attribute. shadow still sees the original class attribute: "Felis catus". (The situation is different for mutable class attributes like lists — we will cover that gotcha in the Mutable Class Attribute Trap section.)
Not quite
No error here. shadow does not have its own species instance attribute, but Python's attribute lookup checks the class next. Since Cat.species still equals "Felis catus", that value is returned without raising an exception.

How Python Looks Up Attributes

Before moving on, it is worth pausing to build a clear mental model of what happens behind the dot. Every attribute access in Python triggers a search through a chain of namespaces. Understanding this chain is the single concept that connects class attributes, instance attributes, @property, name mangling, and __slots__ into one coherent picture.

Python Attribute Lookup Order

The default lookup order for obj.name works like this: Python checks the instance's __dict__ first. If the attribute is not found there, it moves to the class's __dict__. If it still is not found, it walks up the chain of parent classes following the method resolution order (MRO). If nothing is found anywhere, Python raises an AttributeError.

Try it yourself. Click any attribute name below to watch Python trace through the lookup chain, one namespace at a time.

ATTRIBUTE LOOKUP SIMULATOR
Given buddy = Dog("Buddy", 9) where Dog has a class attribute species = "Canis familiaris", click an attribute to trace how Python resolves buddy.<attr>:
instance
name = "Buddy" age = 9
↓ not found, check class
class
species = "Canis familiaris" __init__
↓ not found, check parent
object
__str__ __repr__ __eq__ ...
Click an attribute above to trace the lookup.

Now that you can see the lookup chain working, the next question is: what are all the ways you can interact with attributes along that chain?

Accessing and Modifying Attributes

The simplest way to work with attributes is through dot notation. You can read, create, update, and delete attributes directly.

class Car:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year

my_car = Car("Toyota", "Corolla", 2024)

# Read an attribute
print(my_car.make)  # Toyota

# Update an attribute
my_car.year = 2025
print(my_car.year)  # 2025

# Create a new attribute on the fly
my_car.color = "blue"
print(my_car.color)  # blue

# Delete an attribute
del my_car.color
# print(my_car.color)  # This would raise AttributeError

Python objects are flexible. You can add new attributes to an instance at any time, and you can remove them with the del statement. This is different from many other languages where you must declare every attribute up front.

Watch Out

Just because you can add attributes to an instance anywhere in your code does not mean you should. Defining all attributes inside __init__ makes your code predictable and easier to read. Other developers (and your future self) will thank you. Later in this article, you will see how __slots__ can enforce this discipline at the language level.

Built-in Functions for Working with Attributes

Python provides four built-in functions that let you work with attributes programmatically, using strings for attribute names instead of hard-coded dot notation. These are especially useful when you do not know the attribute name ahead of time, or when you are writing more dynamic, flexible code.

getattr()

The getattr() function retrieves the value of a named attribute from an object. You can also provide a default value that gets returned if the attribute does not exist, which prevents an AttributeError.

class Server:
    def __init__(self, hostname, ip_address):
        self.hostname = hostname
        self.ip_address = ip_address

web_server = Server("web-01", "192.168.1.10")

# Equivalent to web_server.hostname
print(getattr(web_server, "hostname"))  # web-01

# With a default value for a missing attribute
print(getattr(web_server, "os", "Unknown"))  # Unknown
Why This Matters

These four functions — getattr, setattr, hasattr, delattr — are doing the exact same lookup you traced in the simulator above. The only difference is that the attribute name comes from a string variable instead of being written directly in your source code. That one difference opens the door to metaprogramming: code that inspects and modifies objects at runtime based on data it does not know at the time you write it.

setattr()

The setattr() function sets the value of a named attribute on an object. If the attribute does not already exist, it creates it.

# Equivalent to web_server.os = "Ubuntu 24.04"
setattr(web_server, "os", "Ubuntu 24.04")
print(web_server.os)  # Ubuntu 24.04

hasattr()

The hasattr() function checks whether an object has a specific attribute. It returns True or False.

print(hasattr(web_server, "hostname"))    # True
print(hasattr(web_server, "mac_address")) # False

delattr()

The delattr() function removes a named attribute from an object. It works the same as the del statement but accepts the attribute name as a string.

delattr(web_server, "os")
print(hasattr(web_server, "os"))  # False
Pro Tip

Use getattr() with a default value instead of wrapping dot-notation access in a try/except block. It is cleaner and more idiomatic: getattr(obj, "attr", None) is a common pattern you will see throughout Python codebases.

POP QUIZ
What does this code print?
class Router: def __init__(self, model): self.model = model r = Router("Cisco 2901") attr_name = "firmware" result = getattr(r, attr_name, "not set") print(result)
Pick one:
Correct
The Router instance has no attribute called firmware. Because getattr() was called with a third argument ("not set"), that default value is returned instead of raising an AttributeError. Think of it this way: Python runs the full lookup chain — instance, class, parents — finds nothing, and falls back to your default instead of crashing.
Not quite
An AttributeError would occur only if getattr() were called without a default value, like getattr(r, attr_name). Here the third argument "not set" acts as a fallback, so the function returns that string instead of raising an exception.
Not quite
None would be the result if the default were getattr(r, attr_name, None). But in this code the default is the string "not set". The third argument to getattr() can be any value you choose, and that is what gets returned when the attribute is missing.

Special (Dunder) Attributes

Python objects come with a set of built-in attributes that have names surrounded by double underscores, often called "dunder" (double-underscore) attributes. You do not need to create these yourself — Python provides them automatically. Here are some of the ones you will encounter frequently.

class Firewall:
    """A simple firewall rule manager."""

    def __init__(self, name):
        self.name = name
        self.rules = []

    def add_rule(self, rule):
        """Add a rule to the firewall."""
        self.rules.append(rule)

fw = Firewall("perimeter")

# __class__ tells you the type of the object
print(fw.__class__)        # <class '__main__.Firewall'>

# __dict__ shows the instance's attribute namespace
print(fw.__dict__)         # {'name': 'perimeter', 'rules': []}

# __doc__ returns the class docstring
print(Firewall.__doc__)    # A simple firewall rule manager.

# __module__ tells you where the class was defined
print(Firewall.__module__) # __main__

The __dict__ attribute is especially useful for debugging. It returns a dictionary containing all of the instance's attributes and their current values, giving you a clear snapshot of the object's state at any point in your program. It is also the same dictionary Python checks first during the attribute lookup you traced in the simulator.

PAUSE AND PREDICT
Given what you know about __dict__ and the attribute lookup chain, what do you think happens when you assign fw.priority = "high"?
fw = Firewall("perimeter") fw.priority = "high" print(fw.__dict__)
Answer
The output is {'name': 'perimeter', 'rules': [], 'priority': 'high'}. Assigning a new attribute with dot notation writes directly into the instance's __dict__. No class attribute is created or modified. The next time you access fw.priority, Python will find it immediately in the instance namespace — step one of the lookup chain. This is the same mechanism that lets you "shadow" a class attribute on a single instance without affecting others.

Listing All Attributes with dir()

The built-in dir() function returns a list of all valid attribute names for an object. This includes attributes defined on the instance, attributes inherited from the class, and special dunder attributes provided by Python itself.

class Sensor:
    sensor_type = "temperature"

    def __init__(self, location):
        self.location = location

    def read(self):
        return 72.5

s = Sensor("server-room-a")
print(dir(s))

The output will include 'location', 'read', 'sensor_type', along with many dunder attributes like '__class__', '__dict__', '__init__', and others. This makes dir() a handy exploration tool when you are working in the interactive Python shell and want to discover what an object can do.

Note

The dir() function is best used for quick exploration and debugging. For production code, prefer hasattr() to check for a specific attribute rather than scanning through the full list returned by dir(). Unlike filtering dir() output, hasattr() performs a real attribute lookup through the full chain — including descriptors and inherited attributes — so the result is more reliable.

The Mutable Class Attribute Trap

This section builds directly on the shared-vs-unique distinction you saw in class and instance attributes. The mental model is the same — but the consequences are sharper when a mutable object is involved.

One of the more common mistakes beginners make with class attributes involves mutable default values. When a class attribute is a mutable object like a list or dictionary, every instance shares a reference to that same object. Modifying it through one instance changes it for all of them.

class AlertQueue:
    # This list is shared across ALL instances
    alerts = []

    def __init__(self, name):
        self.name = name

    def add_alert(self, message):
        self.alerts.append(message)

queue_a = AlertQueue("east-region")
queue_b = AlertQueue("west-region")

queue_a.add_alert("Brute force detected")
print(queue_b.alerts)  # ['Brute force detected']  <-- unexpected!
class AlertQueue:
    def __init__(self, name):
        self.name = name
        self.alerts = []  # Now each instance gets its own list

    def add_alert(self, message):
        self.alerts.append(message)

queue_a = AlertQueue("east-region")
queue_b = AlertQueue("west-region")

queue_a.add_alert("Brute force detected")
print(queue_b.alerts)  # []  <-- correct

Both instances point to the exact same list object. When queue_a appends to it, queue_b sees the change because there is only one list in memory. The fix is to create the list inside __init__ so each instance gets its own independent copy.

Common Misconception
Wrong mental model
"This is a bug in Python." It is not. It follows directly from the lookup chain: self.alerts.append(...) first reads self.alerts (finding it on the class), then mutates the list in place. No new instance attribute is created because .append() modifies the existing object rather than reassigning self.alerts.
Correct mental model
Assignment (=) creates a new entry in the instance namespace. Mutation (.append(), .update(), etc.) modifies the object wherever it lives. Since the list lives in the class namespace, mutating it through any instance affects all of them.
Watch Out

This trap only applies to mutable objects like lists, dictionaries, and sets. Immutable class attributes like strings, integers, and tuples are safe to share because they cannot be changed in place. If you need a shared counter or constant, a class attribute is the right choice. If you need a per-instance collection, always initialize it in __init__.

Name Mangling and "Private" Attributes

You now know how Python finds attributes and how they can be shared or isolated. The next question is: can you control who accesses them? Python's answer leans on convention, not enforcement.

Python does not enforce strict access control the way languages like Java or C++ do. Instead, it relies on naming conventions to signal intent. There are two conventions you will see everywhere, and one mechanical feature called name mangling that adds a layer of protection.

Single Leading Underscore: _protected

A single underscore prefix is a convention that tells other developers, "this attribute is intended for internal use." Python does not prevent access to it — it is purely a signal. You will find this pattern throughout the standard library and in well-maintained third-party packages.

class DatabaseConnection:
    def __init__(self, host, port):
        self.host = host
        self.port = port
        self._connection = None  # Internal use - not part of the public API

    def connect(self):
        self._connection = f"connected to {self.host}:{self.port}"

db = DatabaseConnection("10.0.0.5", 5432)
db.connect()

# Works fine, but the underscore signals: "use at your own risk"
print(db._connection)  # connected to 10.0.0.5:5432

Double Leading Underscore: __private (Name Mangling)

A double underscore prefix triggers Python's name mangling mechanism. Python rewrites the attribute name internally by prepending _ClassName to it. This makes it harder (but not impossible) for subclasses or external code to accidentally override or access the attribute.

class SecureToken:
    def __init__(self, token):
        self.__token = token  # Name-mangled

    def get_masked(self):
        return self.__token[:4] + "****"

t = SecureToken("abc123xyz")
print(t.get_masked())  # abc1****

# Direct access fails
# print(t.__token)  # AttributeError: 'SecureToken' object has no attribute '__token'

# But the mangled name still exists
print(t._SecureToken__token)  # abc123xyz

Name mangling is not a security feature. Anyone who knows the class name can still reach the attribute. Its real purpose is to prevent accidental collisions when a subclass defines an attribute with the same name as one in a parent class.

Note

Use a single underscore for attributes that are "internal but accessible." Reserve the double underscore for situations where you specifically need to avoid name collisions in inheritance hierarchies. In everyday code, a single underscore is sufficient for signaling intent.

POP QUIZ
What does this code print?
class Vault: def __init__(self, code): self.__code = code v = Vault("open-sesame") print(v._Vault__code)
Pick one:
Correct
Python's name mangling rewrites self.__code to self._Vault__code internally. Accessing v._Vault__code uses the mangled name directly, so it successfully returns "open-sesame". This demonstrates why name mangling is not true security — the data is still accessible if you know the naming pattern. You can even confirm this by checking v.__dict__, which will show the mangled key _Vault__code right there in the dictionary.
Not quite
An AttributeError would occur if you tried v.__code, because that name does not exist after mangling. But v._Vault__code is the mangled name that Python created internally, so the attribute is found and its value is returned: "open-sesame".
Not quite
The attribute _Vault__code exists and holds a real value. Python's name mangling transforms self.__code into self._Vault__code behind the scenes. Since we are using that exact mangled name, Python finds the attribute and returns "open-sesame", not None.

Managed Attributes with @property

So far, every attribute you have worked with has been passive — Python stores a value and hands it back when asked. But what if you want the lookup itself to do something? That is where @property inserts logic into the same lookup chain you have been tracing.

The @property decorator lets you define a method that behaves like an attribute. Code that accesses the attribute triggers the method behind the scenes, giving you a place to add logic without changing the way external code interacts with your class.

Defining a Getter

At its simplest, @property turns a method into a read-only attribute. The method runs whenever the attribute is accessed, and its return value is what the caller receives.

class Subnet:
    def __init__(self, network, prefix_length):
        self._network = network
        self._prefix_length = prefix_length

    @property
    def cidr(self):
        return f"{self._network}/{self._prefix_length}"

subnet = Subnet("192.168.1.0", 24)
print(subnet.cidr)  # 192.168.1.0/24

# This is read-only - trying to set it raises an error
# subnet.cidr = "10.0.0.0/8"  # AttributeError: property 'cidr' has no setter

Notice that cidr is accessed without parentheses, just like a regular attribute. The caller does not need to know or care that a method is running behind the scenes.

Adding a Setter

If you want to allow assignment while still controlling what values are accepted, you add a setter using the @property_name.setter decorator. This is a clean way to build validation directly into attribute assignment.

class FirewallRule:
    VALID_ACTIONS = ("allow", "deny", "drop")

    def __init__(self, port, action):
        self.port = port
        self.action = action  # Triggers the setter below

    @property
    def action(self):
        return self._action

    @action.setter
    def action(self, value):
        if value not in self.VALID_ACTIONS:
            raise ValueError(f"Action must be one of {self.VALID_ACTIONS}, got '{value}'")
        self._action = value

rule = FirewallRule(443, "allow")
print(rule.action)  # allow

rule.action = "deny"
print(rule.action)  # deny

# rule.action = "permit"  # ValueError: Action must be one of ('allow', 'deny', 'drop'), got 'permit'

The key detail here is that the setter runs during __init__ as well, because the line self.action = action triggers it. This means your validation is enforced from the moment the object is created, not just on later updates.

PAUSE AND PREDICT
If you inspect rule.__dict__ after creating a FirewallRule, will you see action or _action as the key?
Answer
You will see {'port': 443, '_action': 'allow'}. The property action is a descriptor that lives on the class, not the instance. When you assign self.action = value, the setter stores the data in self._action, which is the real instance attribute. The property intercepts the access, but the underlying data sits in __dict__ under the private name. This is a direct consequence of the lookup chain: descriptors (like properties) on the class take priority over instance attributes during lookup.

Computed Attributes

Properties are also useful for values that are derived from other attributes. Instead of storing redundant data that can fall out of sync, you compute the value each time it is accessed.

class DiskPartition:
    def __init__(self, total_gb, used_gb):
        self.total_gb = total_gb
        self.used_gb = used_gb

    @property
    def free_gb(self):
        return self.total_gb - self.used_gb

    @property
    def usage_percent(self):
        return round((self.used_gb / self.total_gb) * 100, 1)

disk = DiskPartition(500, 320)
print(disk.free_gb)         # 180
print(disk.usage_percent)   # 64.0

disk.used_gb = 450
print(disk.free_gb)         # 50
print(disk.usage_percent)   # 90.0
Pro Tip

Start with plain attributes. If you later need to add validation or computation, you can swap in a @property without changing any of the code that uses the attribute. This is one of Python's best design features — you never need getter and setter methods "just in case."

Restricting Attributes with __slots__

You have seen that __dict__ is the backbone of attribute storage — Python reads from it, writes to it, and every instance carries its own. But what happens when you remove it entirely?

By default, Python stores each instance's attributes in a dictionary called __dict__. This is what allows you to add new attributes to any object at any time. However, that flexibility comes at a cost: every instance carries the overhead of its own dictionary, and nothing stops you from accidentally creating a misspelled attribute.

The __slots__ declaration tells Python exactly which attributes an instance is allowed to have. Python then uses a more compact internal structure instead of a dictionary, which reduces memory usage and speeds up attribute access slightly.

class LogEntry:
    __slots__ = ("timestamp", "level", "message")

    def __init__(self, timestamp, level, message):
        self.timestamp = timestamp
        self.level = level
        self.message = message

entry = LogEntry("2026-03-16T10:30:00", "WARNING", "Disk usage above 90%")
print(entry.level)  # WARNING

# Attempting to add an attribute not declared in __slots__ fails
# entry.source = "syslog"  # AttributeError: 'LogEntry' object has no attribute 'source'

# The instance no longer has a __dict__
# print(entry.__dict__)  # AttributeError: 'LogEntry' object has no attribute '__dict__'

Because there is no __dict__, each instance uses less memory. The difference is small for a handful of objects, but it adds up quickly when you are creating thousands or millions of them — parsing log files, processing network packets, or building nodes in a graph.

Connecting the Dots

Notice how __slots__ directly changes the lookup chain from the simulator. Without __dict__, the instance namespace works differently — Python uses fixed-offset descriptors on the class instead of a dictionary lookup. This is also why __slots__ prevents dynamic attribute creation: there is no dictionary to write into. And it is why the mutable class attribute trap plays out differently with slotted classes — there is no instance __dict__ to accidentally shadow the class attribute in.

When to Use __slots__

The __slots__ declaration is not something you need on every class. It makes the most sense in a few specific situations: when you are creating a very large number of instances with a fixed set of attributes, when you want to prevent accidental attribute creation caused by typos, or when you are working in a performance-sensitive part of your codebase and profiling shows that object memory overhead is a bottleneck.

# Using __slots__ with dataclasses (Python 3.10+)
from dataclasses import dataclass

@dataclass(slots=True)
class Packet:
    src_ip: str
    dst_ip: str
    protocol: str
    size_bytes: int

pkt = Packet("10.0.0.1", "10.0.0.2", "TCP", 1500)
print(pkt.src_ip)  # 10.0.0.1

# Still blocked from adding unexpected attributes
# pkt.payload = b"\x00"  # AttributeError
Note

If you use __slots__ in a parent class, child classes will still get a __dict__ unless they define their own __slots__ as well. When combining __slots__ with inheritance, only include the new attribute names in the child's __slots__ — attributes from the parent are already covered.

Key Takeaways

  1. Attributes are names accessed with dot notation. Any value you reach through object.name is an attribute, whether it is a piece of data or a method.
  2. Class attributes are shared; instance attributes are not. Class attributes live on the class and are available to every instance. Instance attributes are created on individual objects and are unique to each one.
  3. The lookup chain is the unifying concept. Python checks the instance namespace first, then the class, then parent classes. Every feature in this article — shadowing, name mangling, @property, __slots__ — is a variation on how that chain behaves.
  4. Mutable class attributes are shared references. A list or dictionary defined as a class attribute points to a single object in memory. Modifying it through one instance affects all of them. Create mutable collections inside __init__ instead.
  5. Underscore conventions signal intent. A single leading underscore marks an attribute as internal. A double leading underscore triggers name mangling to help avoid collisions in subclasses.
  6. The @property decorator creates managed attributes. Use it to add validation, compute derived values, or make attributes read-only — all without changing the public interface of your class.
  7. __slots__ restricts and optimizes attribute storage. Declaring __slots__ removes the per-instance __dict__, reducing memory overhead and preventing accidental attribute creation.
  8. Python provides four built-in attribute functions. getattr(), setattr(), hasattr(), and delattr() let you work with attributes dynamically using string names.
  9. Dunder attributes give you access to object internals. Attributes like __dict__, __class__, and __doc__ are built into every object and provide useful metadata for debugging and introspection.
  10. Use dir() for exploration and hasattr() for checks. When you need to discover what an object offers, dir() is your friend. When you need to verify a specific attribute exists in running code, reach for hasattr().

Attributes are the mechanism Python uses to attach data and behavior to objects, and they show up everywhere in the language. Whether you are accessing len on a list, pi on the math module, or a custom name on a class you wrote yourself, you are using attributes. Once you are comfortable with how they work — including the lookup order between instances and classes, the conventions around naming, and the tools for managing access — you will have a much stronger understanding of how Python programs are structured.

back to articles