Encapsulation: Public, Protected, and Private Members

by Christoph Schiessl on Python

Learning this may surprise or even shock you: Python doesn't support truly private or protected class members. Therefore, encapsulation in the OOP sense is, strictly speaking, not supported.

What is Encapsulation?

Encapsulation is one of the pillars of Object-Oriented Programming: hiding a class's internal state (i.e., instance variables) from the outside so that the state cannot be directly accessed and mutated. This is important to protect the state from arbitrary, uncontrolled mutations. Usually, this is accomplished by forcing mutations to go through the class's methods, which can implement safeguards to allow only specific mutations and reject others. The way to enforce the usage of the class's methods is to make the state itself private or protected from the outside.

Public members are accessible from outside the class, and private members are only accessible from within the class itself. So, what are protected members? They are the middle ground because they are directly accessible from within the class itself and its subclasses. This is not true for private members because they cannot be directly accessed from within subclasses.

Public Members

You don't have to do anything special to get public members because this is the default behavior. For instance, the method A.foo() is public, and therefore, it's accessible from every context.

Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class A:
...   def foo(self):
...     print("A.foo() has been called!")
...   def bar(self):
...     print("Calling A.foo() from within A ...")
...     self.foo()
...
>>> class B(A):
...   def baz(self):
...     print("Calling A.foo() from within subclass B ...")
...     self.foo()
...
>>> A().foo() # Calling A.foo() from the outside ...
A.foo() has been called!
>>> A().bar()
Calling A.foo() from within A ...
A.foo() has been called!
>>> B().baz()
Calling A.foo() from within subclass B ...
A.foo() has been called!

Protected Members

There's no mechanism in Python to make members protected, but there is a convention to prefix members with an _ to suggest that they are not supposed to be used from the outside. In the PEP8 section on naming styles, the underscore is referred to as a weak "internal use" indicator.

Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class A:
...   def _foo(self):
...     print("A._foo() has been called!")
...   def bar(self):
...     print("Calling A._foo() from within A ...")
...     self._foo()
...
>>> class B(A):
...   def baz(self):
...     print("Calling A._foo() from within subclass B ...")
...     self._foo()
...
>>> A()._foo() # Calling A.foo() from the outside ...
A._foo() has been called!
>>> A().bar()
Calling A._foo() from within A ...
A._foo() has been called!
>>> B().baz()
Calling A._foo() from within subclass B ...
A._foo() has been called!

As you can see, the leading _ doesn't affect privacy because, technically, the member is still public. It's just an agreed-upon convention without enforcement. The only effect that I'm aware of is for wildcard imports. Namely, statements such as from M import * would not import identifiers with a leading underscore.

Private Members

Last but not least, you can also prefix members with two underscores to suggest that they are private, which results in their names getting mangled. But even in this case, they are technically still public and can be accessed from the outside, but you have to know how exactly name mangling works.

Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class A:
...   def __foo(self):
...     print("A.__foo() has been called!")
...   def bar(self):
...     print("Calling A.__foo() from within A ...")
...     self.__foo()
...
>>> class B(A):
...   def baz(self):
...     print("Calling A.__foo() from within subclass B ...")
...     self.__foo()
...
>>> A().__foo() # Calling A.foo() from the outside ...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'A' object has no attribute '__foo'. Did you mean: '_A__foo'?
>>> A().bar()
Calling A.__foo() from within A ...
A.__foo() has been called!
>>> B().baz()
Calling A.__foo() from within subclass B ...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in baz
AttributeError: 'B' object has no attribute '_B__foo'. Did you mean: '_A__foo'?

So, we can call A.__foo() from within A, but we get AttributeErrors if we try to call it from outside or a subclass of A. Also, the error messages already suggest how mangling works: A member __foo of a class A is renamed to _A__foo — knowing this, we can easily circumvent the effect of the leading __.

Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class A:
...   def __foo(self):
...     print("A.__foo() has been called!")
...   def bar(self):
...     print("Calling A.__foo() from within A ...")
...     self.__foo()
...
>>> class B(A):
...   def baz(self):
...     print("Calling A.__foo() from within subclass B ...")
...     self._A__foo()
...
>>> print("Calling A.__foo() from the outside ..."); A()._A__foo()
Calling A.__foo() from the outside ...
A.__foo() has been called!
>>> A().bar()
Calling A.__foo() from within A ...
A.__foo() has been called!
>>> B().baz()
Calling A.__foo() from within subclass B ...
A.__foo() has been called!

Please don't confuse this with magic methods that must start and end with __ (e.g., __init__).

Conclusion

As I said initially, strictly speaking, Python doesn't support private or protected members. Instead, there are conventions to express these concepts, but there is no enforcement. Also, as a developer, nobody forces you to follow the conventions. That said, I strongly advise that you follow the convention because not doing so will make it much harder for others to understand your code. Anyway, that's everything for today. Thank you for reading, and see you soon!

Ready to Learn More Web Development?

Join my Mailing List to receive two articles per week.


I send two weekly emails on building performant and resilient Web Applications with Python, JavaScript and PostgreSQL. No spam. Unscubscribe at any time.

Continue Reading?

Here are a few more Articles for you ...


Function Definition with Position-Only Parameters

Learn about positional parameters in Python and a special syntax that allows functions to declare certain parameters as position-only.

By Christoph Schiessl on Python

Function Definition Basics

Explore Python's function definition statement and discover its features with this series of articles. Get started with this simple introduction.

By Christoph Schiessl on Python

The Built-in sum() Function

In this article, we explore Python's built-in sum() function, its parameters, and some extreme use cases it wasn't even designed for.

By Christoph Schiessl on Python

Christoph Schiessl

Christoph Schiessl

Independent Consultant + Full Stack Developer


If you hire me, you can rely on more than a decade of experience, which I have collected working on web applications for many clients across multiple industries. My involvement usually focuses on hands-on development work using various technologies like Python, JavaScript, PostgreSQL, or whichever technology we determine to be the best tool for the job. Furthermore, you can also depend on me in an advisory capacity to make educated technological choices for your backend and frontend teams. Lastly, I can help you transition to or improve your agile development processes.