Python Property: A Guide to Managed Attributes

A property or a managed attribute allows you to create methods in your classes that behave like attributes.
Andrew Wood  •   26 June 2022
Andrew Wood  •   Last Updated: 26 June 2022
A property or a managed attribute allows you to create methods in your classes that behave like attributes.

This tutorial will guide you through the use of Python's property() function which is designed to allow you to work with managed attributes when building and maintaining classes. It is most often called as the @property decorator in a class, and is used to convert a method within the class to a property or managed attribute.

Introduction to Managed Attributes

In order to understand the concept of a property or managed attribute, you first need to be familiar with the difference between an attribute and a method within a class.

Class Construction: Methods and Attributes

Classes are comprised of methods and attributes.

  • A method is a function that is stored within the body of class.
  • An attribute is a value stored within an instance of a class, and is accessed using Python's dot notation. 

In the Person class below there are two attributes: name and age, and a single method details which when called will return a string of the person's name and age. 

class Person:
    def __init__(self,name,age):
        self.name = name # attribute name
        self.age = age # attribute age
    
    def details(self): # method
        return f"My name is {self.name} and I'm {self.age} years old."

If we now create a new Person object it's easy to demonstrate the difference between a method and an attribute. Note that to execute the details method we need to use a pair of brackets.

>>> newperson = Person("Bob",28)

>>> print(newperson.name) # print attribute name
"Bob"
>>> print(newperson.age) # print attribute age
28
>>> print(newperson.details()) # call details method
"My name is Bob and I'm 28 years old."

Managed Attributes

In our Person class example above, we defined an instance of the Person class which we named newperson. We assigned the name "Bob" to the name attribute of newperson. There is now nothing stopping us changing "Bob's" name.

>>> newperson.name = "Sandy"
>>> print(newperson.details())
"My name is Sandy and I'm 28 years old."

Bob has now become Sandy! We have exposed our name and age attributes directly to the user which means they can be accessed and changed (mutated) at will. There is also nothing stopping a user instantiating the class incorrectly - what happens if the user reverses name and age when creating a class instance?

>>> myperson = Person(34,"Rex")
>>> print(myperson.details())
"My name is 34 and I'm Rex years old."

A more robust approach when working with classes is to manage the attribute by housing it in a method. Then if you ever need to change the internal implementation of the attribute you can do so without breaking any previous iteration of the class. This is important, especially if you release your code into the wild where people may be working with different versions of your codebase.

  • Property or Managed Attribute is somewhere between an attribute and a method. It allows you to create a method within your class but then access it as you would any attribute.

In our myperson example we inadvertantly mixed the name and age around. Had we used a managed attribute for both name and age then we could have added some data validation to reduce the likelyhood of incorrect inputs being made.

Python's Approach to Managing Attributes

Public and Non-Public Attributes

Python makes a disctinction between public and non-public attributes through the convention of a leading underscore prefixed to the attribute you wish to make non-public.

It is important to note that nothing stops a user from actually accessing these non-public attributes, the standard Python dot notation still works on these attributes. It is however, poor programming practice to do so and so should be avoided.

class Person:
    def __init__(self,name,age):
        self.name = name # attribute name
        self.age = age # attribute age
        self._email = f"{self.name.lower()}@company.com" # non-public attribute
>>> p1 = Person("John",23)

>>> print(p1.name)
"John"
>>> print(p1._email) # can still access the non-public attribute!
"john@company.com"

Getter and Setter Methods

In many programming languages you are encouraged not to expose your attributes directly to users for the reasons discussed in the two sections above. Rather the convention is to write getter and setter methods to retrieve and set the attributes respectively. Some languages, like C++ for example, allow you to define attributes as either publicprivate, or protected. A getter or setter method makes sense in this situation, as you can then define the attribute to be private, and use a method to retrieve or set a new value for the attribute.

We have already shown that all attributes in Python are accessible to the user, even if we make use of the leading underscore convention to indicate when an attribute is non-public. Therefore it does not really make sense to define non-public attributes in a Python class, and then write a method to set or access them, unless we plan on doing further work (validation, calculation etc) on them, or would like to potentially alter the implementation of the attribute in the future.

This doesn't mean that there isn't a place for getter and setter type functionality within the Python environment - only that we shouldn't needlessly add complexity to the class for simple attributes.

There are many instances in Python classes where we do need to make use of a managed attribute: managing permissions, creating computed attributes, data validation etc. Python gives us all this functionality through the property() function which is typically used as a decorator within our class. 

Introduction to the Property Function

The property() function is used to transform vanilla attributes into managed attributes (properties). The function can be called directly within a class and consists of four arguments representing getter, setter and deleter functionality along with a docstring.  

property(fget=None, fset=None, fdel=None, doc=None)

# fget - getter functionality to return an attribute value
# fset - setter functionality to set an attribute value
# fdel - deleter functionality to delete an attribute stored value
# doc - provision for a property docstring

Since property() is a function (actually a class that looks and works like a function) we can call it within a class as you would any other function. We'll demonstrate this implementation first and then move on to the more common practice of using property as a decorator.

Using Property as a Function

To demonstrate the use of property let's extend our Person class to include a last name and an email address.

  • Last name will need getter and setter type functionality to allow us to set/modify and view the last name.
  • Email address in this example only requires getter type functionality as we'll define the email address internally as firstname.lastname@company.com.

Since we are going to set our last name through the property function, the lastname argument that is initialized when creating a new instance of the class will be made non-public. The methods within the class to generate an email address and to get and set the last name are also non-public. All non-public entities are named with a leading underscore as per Python convention.

Let's write out the updated class and then discuss how it was put together.

class Person:
    def __init__(self,firstname,lastname,age):
        self.firstname = firstname
        self._lastname = lastname # non-public attribute
        self.age = age 
    
    def _get_lastname(self):
        return self._lastname
    
    def _set_lastname(self,value):
        print(f"Last name changed to {value}.")
        self._lastname = value

    lastname = property( # property that is called like an attribute
        fget = _get_lastname,
        fset = _set_lastname,
    )

    def _get_email(self):
        return f"{self.firstname.lower()}.{self.lastname.lower()}@company.com"
    
    email = property(fget=_get_email) # property that is called like an attribute

We have removed the name attribute from the class and added a firstname and lastname attribute instead. Since this is just for demonstration purposes we will leave firstname as a normal attribute and only turn lastname into a property.

During the initialization of the class we create self._lastname to indicate that this is a non-public attribute. Our managed attribute will be called lastname which we define using the property function.

Recall that property takes four optional arguments:

property(fget=None, fset=None, fdel=None, doc=None)

In the case of lastname we need to supply methods for fget and fset to access and set the last name respectively. 

  • _get_lastname will return the last name.
  • _set_lastname will set the last name. 

Finally we need to put the managed attribute lastname together by calling property and assigning our non-public methods to the applicable arguments.

lastname = property(
        fget = _get_lastname,
        fset = _set_lastname,
)

The methodology behind accessing the email address is much the same. Here we don't want to give a user of our class the ability to set their own email address - rather the address will be set as a combination of the first and last name. In reality of course we could run into some problems if this is how we handled email addresses as it is possible that the organization may employ two people with the same first and last name.

def _get_email(self):
        return f"{self.firstname.lower()}.{self.lastname.lower()}@company.com"
    
email = property(fget=_get_email) # property that is called like an attribute

To see this in action let's create a person and check her email address before and after changing her last name.

>>> p2 = Person('Penny','Porpoise',45)
>>> print(p2.lastname)
'Porpoise'
>>> print(p2.email)
'penny.porpoise@company.com'

# now last name changes
>>> p2.lastname = 'Tortoise'
'Last name changed to Tortoise'
>>> print(p2.email)
'penny.tortoise@company.com'

It works! Let's clean up the implementation a bit and use the more popular decorator implementation of property.

Using Property as a Decorator

In practice, writing out property as a function as demonstrated in the section above is rarely used. A far neater implementation of the same functionality can be had by making use of the decorator @property. Decorators were covered in far more detail in this article which is recommended reading before continuing here.

Let's rewrite the Person class but this time use the @property decorator rather than calling property like a function. The syntax is perhaps a little non-intuitive for first-time users, so before we apply it to our class, let's first show how it's written and implemented.

@property # this is equivalent to fget
def managed_attribute_name(self):
    """Here is the docstring i.e 'doc' argument """
    return self._attribute

@managed_attribute_name.setter # equivalent to fset
def managed_attribute_name(self,value):
    self._attribute = value
    
@managed_attribute_name.deleter # equivalent to fdel
def managed_attribute_name(self):
    del self._attribute
  • managed_attribute_name is the name of the property: in our example this will be lastname.
  • Adding the @property decorator above the def managed_attribute_name() applies fget to that def.
  • Adding a docstring to the fget function sets the docstring for the entire property/managed attribute.
  • We then use the name of the managed attribute as a part of our setter and del decorator, making sure that the def managed_attribute_name() is correctly named to apply the fset and del methods respectively. 

Let's apply this to our Person class and see it in action.

class Person:
    def __init__(self,firstname,lastname,age):
        self.firstname = firstname
        self._lastname = lastname
        self.age = age
        
    @property
    def lastname(self):
        """Last name managed attribute."""
        return self._lastname
    
    @lastname.setter
    def lastname(self,value):
        self._lastname = value
        
    @lastname.deleter
    def lastname(self):
        print(f'Last name: {self.lastname} deleted.')
        del self._lastname
    
    
    @property
    def email(self):
        try:
            return f"{self.firstname.lower()}.{self.lastname.lower()}@company.com"
        except:
            return f"{self.firstname.lower()}@company.com"

Since we've added the ability to delete the last name, I have made a small modification to the email property so as not to throw out an error when trying to create the email address without a last name. If there is no last name present in the instance of the Person object then the email address will simply revert to firstname@company.com. If you want to learn more about handling exceptions then head over to our guide on errors and exception handling.

Let's test out the functionality of our class.

>>> p3 = Person('Penny','Porpoise',45)
>>> print(p3.email)
'penny.porpoise@company.com'

>>> p3.lastname = "Tortoise"
>>> print(p3.email)
'penny.tortoise@company.com'

>>> del p3.lastname
'Last name: Tortoise deleted'
>>> print(p3.email)
'penny@company.com'

If you copy this class into your own workspace and run through the example, you'll see that it is working as we'd expect. By making use of the property decorator we are able to treat the email and lastname as attrbutes even though there have methods underlying their implementation. Updating or removing the last name also updates the email address as expected.

Let's now take what we have learnt and apply it to managing permissions on our attributes.

Using Property to Manage Permissions

The property decorator provides us with an easy means to control read and write permissions on our object attributes. We'll go through an example on how to build in these permissions now.

Read-Only

The fget method is analogous to a getter method in other languages. If we build a class with an attribute that we'd like to set at the object instantiation and thereafter make it read-only, we can use fget to do so. Remember that we call fget using the @property decorator as shown in the example below.

Our example here is a simple class that stores the two-dimensional coordinates of a point. Once the coordinates of a point are defined we do not want to be able to modify that point. 

class Coordinate:
    def __init__(self,x,y):
        self._x = x
        self._y = y
    
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
>>> mypoint = Coordinate(10,13)
>>> print(f"The coordinates of the point (x,y) are ({mypoint.x},{mypoint.y}).")
"The coordinates of the point (x,y) are (10,13)."

  Let's try and modify the x-coordinate.

# now try change the x-coordinate to 3
# This won't work as the setter method not defined.

>> mypoint.x = 3

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_6080\1067078135.py in <module>
      4 # now try change x to 12
      5 # This won't work as the setter method not defined.
----> 6 mypoint.x = 12

AttributeError: can't set attribute

Write-Only

To make a variable write-only we need to make use of a setter function (fset in the property function) while disallowing access to fget. We can do so by raising an attribution error when fget is accessed. You may be wondering when would we ever need to make an attribute write-only?

One good example is if you are building in password functionality into an application. The user needs to create a password in a human readable format, but this should never be saved in plain-text on the server. The password input would be hashed and stored as a hash value which is not human readable.

class User:
    def __init__(self, name, password):
        self.name = name
        self.password = password

    @property
    def password(self):
        raise AttributeError("Password is write-only")

    @password.setter
    def password(self, plaintext):
    # here you would hash the password and store
    # the hash value as a non-public attribute.

If you try to access the password property you will raise an AttributeError.

aw = User('aw','swpass')
print(aw.password)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_6080\1175680226.py in <module>
      1 aw = User('aw','swpass')
----> 2 aw.password

~\AppData\Local\Temp\ipykernel_6080\1883024096.py in password(self)
     12     @property
     13     def password(self):
---> 14         raise AttributeError("Password is write-only")
     15 
     16     @password.setter

AttributeError: Password is write-only

Read & Write

We have already created a class where we specified read-write attributes. Take another look at the section Using Property as a Decorator to familiarise yourself again with the fget and fset arguments of the property function.

Additional Property Examples

We've shown how to use @property to manage read and write permissions and discussed the function's importance when it come to creating managed attributes rather than class attributes. Let's look at a few more applications (by no means the only applications) where making use of properties in your classes are a good idea.

Computed Attributes

A computed attribute is a managed attribute that is calculated when the attribute is called. A good example is the area of a circle which is calculated based on that circle's radius. Since computed attributes are calculated only when called, the area value will use the current radius and so will always provide an up-to-date result. This however can be a bit of a double-edge sword, as calculating the value on demand can become computationally expensive if called often and may slow down the program significantly. In these instances it would be better to create a cached attribute and only update the value when the cached value is outdated. 

Let's first implement the class without caching.

import math

class Circle:
    def __init__(self,radius):
        self.radius = radius
    
    @property
    def area(self):
        return round(math.pi*self.radius**2,2)
    
    @property
    def perimeter(self):
        return round(2*math.pi*self.radius,2)
>>> circle = Circle(10)
>>> print(circle.area)
314.16
>>> print(circle.perimeter)
62.83

Caching

Now we'll extend our Circle class to include caching on the area attribute. 

If you have created a computed attribute that you use frequently it becomes computationally expensive to perform that same calculation each time the attribute is called if the inputs to the calculation remains unchanged. In this instance, it is preferable to cache the result in a non-public attribute and reuse it later. When the input values into the computed attribute change then the attribute should be automatically recalculated (when called) so as to be up-to-date.

I have included a counter in this caching example in order to show that the caching is working correctly. Each time the radius is set or reset, the _area attribute is automatically set to None. This indicates that the area must be calculated when called. If the area is not None then an up-to-date value for area exists and can be used without recalculation. The radius attribute must be made public so that the @radius.setter functionality is run when the class instance is initialised.

import math

class Circle:
    def __init__(self,radius):
        self.radius = radius
        
    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        self._area = None # reset area when radius changes
        self._count = 0 # reset counter
        self._radius = value
    
    @property
    def area(self):
        if self._area == None:
            self._area = round(math.pi*self.radius**2,2)
            print(f"Area: {self._area} cached for the 1st time.")
        else:
            self._count +=1 # increment the counter 
            print(f"Number of times cache accessed: {self._count} time(s).")
        return self._area
    
    @area.setter
    def area(self):
        self._area = self.area

To demonstrate our cache in action let's create a circle of radius 10 and call area three times on it before increasing the area to 20, and calling it again a few times.  

circle = Circle(10)
print(circle.area)
print(circle.area)
print(circle.area)
circle = Circle(20)
print(circle.area)
print(circle.area)
print(circle.area)

Output:

'Area: 314.16 cached for the 1st time.'
314.16
'Number of times cache accessed: 1 time(s).'
314.16
'Number of times cache accessed: 2 time(s).'
314.16

'Area: 1256.64 cached for the 1st time.'
1256.64
'Number of times cache accessed: 1 time(s).'
1256.64
'Number of times cache accessed: 2 time(s).'
1256.64

Validation of Input Values

Property methods are very useful when data validation is required on an input argument. The trick is to force the argument to be run through the setter method on initialization to ensure that the validation is performed. To to this you need to initialise the attribute as public rather than non-public during initialization and then perform the validation on the setter method.

This is easier to follow with an example. Let's return again to our Person class and this time add some data validation on the age attribute. In this case we want the age to be both an integer and positive. 

class Person:
    def __init__(self,firstname,lastname,age):
        self.firstname = firstname
        self.lastname = lastname
        self.age = age
        
    @property
    def age(self):
        return self._age
        
    @age.setter
    def age(self,value):
        try:
            self._age = int(value)
            if not self._age > 0:
                raise Exception('Age must be a positive number!') 
        except ValueError:
            raise ValueError("'Age' is not a valid input") from None

Let's test our class with two examples.

First we incorrectly add a string to the age attribute.

p4 = Person('Chip','Chop','chow')
print(p4.age)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_6080\738327868.py in <module>
     18             raise ValueError("'Age' is not a valid input") from None
     19 
---> 20 p4 = Person('Chip','Chop','chow')
     21 print(p4.firstname)
     22 print(p4.age)

~\AppData\Local\Temp\ipykernel_6080\738327868.py in __init__(self, firstname, lastname, age)
      3         self.firstname = firstname
      4         self.lastname = lastname
----> 5         self.age = age
      6 
      7     @property

~\AppData\Local\Temp\ipykernel_6080\738327868.py in age(self, value)
     16                 raise Exception('Age must be a positive number!')
     17         except ValueError:
---> 18             raise ValueError("'Age' is not a valid input") from None
     19 
     20 p4 = Person('Chip','Chop','chow')

ValueError: 'Age' is not a valid input

It's worth remembering that if you had in fact added an integer number as a string then a valid result would have been returned as the int(value) line in the setter would have been able to transform the str to int

If we try to input a negative age then our own exception will be raised.

p4 = Person('Chip','Chop',-26)
print(p4.age)
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_6080\1477774832.py in <module>
     18             raise ValueError("'Age' is not a valid input") from None
     19 
---> 20 p4 = Person('Chip','Chop',-26)
     21 print(p4.firstname)
     22 print(p4.age)

~\AppData\Local\Temp\ipykernel_6080\1477774832.py in __init__(self, firstname, lastname, age)
      3         self.firstname = firstname
      4         self.lastname = lastname
----> 5         self.age = age
      6 
      7     @property

~\AppData\Local\Temp\ipykernel_6080\1477774832.py in age(self, value)
     14             self._age = int(value)
     15             if not self._age > 0:
---> 16                 raise Exception('Age must be a positive number!')
     17         except ValueError:
     18             raise ValueError("'Age' is not a valid input") from None

Exception: Age must be a positive number!

Attribute validation is a powerful tool when creating classes and one that is made easy through the use of property.

Wrapping Up

This has been a long tutorial and we've covered a lot of ground. Let's summarise what we've learnt as we wrap up this guide to the property function.

  • A property or a managed attribute allows you to create methods in your classes that act like attributes. This is very useful as it means that you can modify the underlying implementation of the managed attribute without actually changing the public API of the class.
  • Python provides us with the property function to get, set, and delete managed attributes. You can also set a docstring from property.
  • The recommended way of using property is through the decorator @property. Add the decorator above your managed attribute def to access the property functionality.
  • There are many instances when you should consider using managed attributes in your classes. We just looked at a few of the common cases:
    • We looked at how read and write permissions can be managed using @property.
    • We discussd computed attributes and caching.
    • We finished off with an example of how to perform field validation using @property.

Thanks for going through this tutorial and sticking around to the end. Properties are a powerful way to add additional functionality to your classes and it is well worth taking the time to familiarize yourself with how they work and when to use them.

Share this
Comments
Canard Analytics Founder. Python development, data nerd, aerospace engineering and general aviation.
Profile picture of andreww
Share Article

Looking for a partner on your next project?

Contact Us