Inventory Tracker with Python Dataclasses

Build a CLI inventory tracker using Python dataclasses from scratch.

Introduction

30 Second Summary

Every Python program that works with structured data needs a way to organize it. Writing full classes by hand means repeating the same boilerplate code over and over for every new data type you create.

In this project, you will build a command-line inventory tracker using Python dataclasses. You will start with a regular class to feel the pain, then rebuild it in five lines and progressively add features like computed fields, immutable records, and sorting.

What You'll Build

A command-line inventory tracker that manages products in a store, demonstrating every major dataclass feature from automatic methods to JSON export.

By the end of this project, you'll have:

  • A Product dataclass with automatic __init__, __repr__, and __eq__ methods generated for you.
  • An InventoryItem with computed fields that recalculate automatically when stock changes.
  • Immutable price records and a sortable inventory using frozen and ordered dataclasses.
  • Secret Mission: A discount system using field exclusion and custom methods.

Are there any prerequisites?

You need basic Python knowledge (variables, functions, lists, and dicts). No prior experience with classes is required.

Before We Start

Before We Start...

Set Up Your Python Environment

Before you can build the inventory tracker, you need Python installed on your machine and a code editor to write in.

In this step, you will install Python, set up VS Code as your editor, and create the project folder where your inventory tracker will live.

In this step, get ready to:
  • Install Python 3.12 or higher.
  • Install VS Code with the Python extension.
  • Create your project folder and first Python file.
Install Python

Python is the programming language you will use to build the inventory tracker. You need version 3.12 or higher installed on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.

Check whether Python is already installed by running the version command for your operating system.

macOS

  • Check your Python version by running:
python3 --version

Windows

  • Check your Python version by running:
python --version

✔️ I see version 3.12 or higher

You should see output like Python 3.12.x, Python 3.13.x, or Python 3.14.x. Python is ready to go.

ⓧ I see an older version

If you see a version lower than 3.12 (like 3.9 or 3.11), you need to install a newer version.

  • Go to https://www.python.org/downloads/ and download the latest Python installer.
  • Run the installer. On Windows, check Add Python to PATH before clicking Install Now.
  • Close your terminal completely and reopen it.
  • Run the version command again to confirm you now see 3.12 or higher.

macOS after installing a new version

macOS does not ship with Python pre-installed. If you previously had an older version, the new installer should update your python3 command automatically.

If python3 --version still shows the old version, try running python3.12 --version or python3.13 --version to use the newly installed version directly.

ⓧ Command not found

Python is not installed yet. Follow these steps to install it.

  • Go to https://www.python.org/downloads/ and download the latest Python installer.
  • Run the installer. On Windows, check Add Python to PATH before clicking Install Now.
  • Close your terminal completely and reopen it.
  • Run the version command again to confirm Python is installed.

Still not seeing Python?

On Windows, the most common cause is a missing PATH entry. Uninstall Python from Settings > Apps, then reinstall with Add Python to PATH checked.

Help me troubleshoot my Python installation.

Install VS Code

VS Code is a free code editor that gives you syntax highlighting, a built-in terminal, and extensions for Python development.

  • Go to https://code.visualstudio.com/ and download the installer for your operating system.
  • Run the installer and follow the prompts to complete the installation.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

Now install the Python extension so VS Code can understand your Python files.

  • Click the Extensions icon in the left sidebar (it looks like four squares).
  • Type Python in the search bar.
  • Find the extension called Python by Microsoft (it should be the first result).
  • Click Install.

What does the Python extension do?

The Python extension adds syntax highlighting, error detection, and code completion for Python files. It also lets you run Python scripts directly from VS Code.

Create your project folder

Every project needs a dedicated folder to keep its files organized. You will create a folder called inventory-tracker on your Desktop and set it up as your workspace in VS Code.

  • In VS Code, open the terminal by clicking Terminal in the top menu bar, then New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir inventory-tracker
cd inventory-tracker
  • Create the empty Python file where your code will live by running:

macOS

touch inventory.py

Windows

New-Item inventory.py -ItemType File
  • Confirm the file exists by running:
ls

You should see inventory.py in the output.

Now open this folder as your VS Code workspace so the editor knows where your project lives.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the inventory-tracker folder, then click Open.

VS Code reloads with inventory-tracker as your workspace. You should see inventory.py listed in the Explorer panel on the left.

Don't see inventory.py in the Explorer?

Make sure you selected the inventory-tracker folder itself, not the Desktop folder. The Explorer panel should show the folder name at the top.

Help me find my project folder in VS Code.

Your environment is ready. Next up, you will write a regular Python class the hard way to see exactly how much boilerplate dataclasses will save you.

See Why Dataclasses Exist

Your environment is set up and ready to go. Now it's time to write some Python and discover the problem that dataclasses solve.

Every time you create a Python class to hold data, you end up writing the same repetitive methods over and over. In this step, you'll feel that pain first by writing a class the hard way, then watch it disappear with a single decorator.

In this step, get ready to:
  • Write a full ProductOld class with __init__, __repr__, and __eq__ methods by hand.
  • Replace it with a Product dataclass that achieves the same result in fewer lines.
  • Test automatic equality comparison between two dataclass instances.
Write the ProductOld class by hand

To appreciate what dataclasses give you, you need to see what life looks like without them. A typical data-holding class in Python needs at least three special methods just to behave sensibly.

  • Open inventory.py in VS Code and add the following imports and class definition:
from dataclasses import dataclass, field, asdict
import json


# Step 2: The boilerplate problem - regular class
class ProductOld:
    def __init__(self, name: str, price: float, quantity: int):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return f"ProductOld(name='{self.name}', price={self.price}, quantity={self.quantity})"

    def __eq__(self, other):
        if not isinstance(other, ProductOld):
            return NotImplemented
        return (self.name == other.name and
                self.price == other.price and
                self.quantity == other.quantity)

What does this code do?

The import line at the top pulls in everything you'll need from the dataclasses module across this project. You'll use field and asdict in later steps.

The ProductOld class manually defines three special methods:

  • __init__ sets up each new instance with name, price, and quantity.
  • __repr__ controls what you see when you print an instance.
  • __eq__ lets you compare two instances with == based on their field values.
  • Save inventory.py.
  • You won't see any output yet. The next section adds a __main__ block that runs this code.
Replace it with a Product dataclass

The @dataclass decorator tells Python to generate those same __init__, __repr__, and __eq__ methods automatically from a list of typed fields.

  • Add the following code below ProductOld in inventory.py:
# Step 2 & 3: The dataclass solution
@dataclass
class Product:
    name: str
    price: float
    quantity: int


if __name__ == "__main__":
    # Step 2: Compare old vs new
    print("=== Regular Class vs Dataclass ===")
    old = ProductOld("Widget", 9.99, 100)
    new = Product("Widget", 9.99, 100)
    print(f"Old: {old}")
    print(f"New: {new}")

What does this code do?

The Product class does the same thing as ProductOld in just 5 lines. The @dataclass decorator reads the type annotations (name: str, etc.) and generates all the boilerplate methods for you.

The if __name__ == "__main__": block ensures the code below it only runs when you execute the script directly. It creates one instance of each class and prints them so you can compare the output side by side.

  • Save inventory.py.
  • Run the script by entering this command in your VS Code terminal:
python inventory.py

You should see both representations printed, looking identical despite one class being 15 lines and the other being 5.

Seeing a SyntaxError or no output?

  • Make sure your @dataclass decorator sits directly above class Product: with no blank lines in between.
  • Check that the if __name__ == "__main__": line is at the very bottom of the file, not indented inside a class.
  • Confirm your terminal is in the inventory-tracker folder by running pwd (macOS/Linux) or cd (Windows).

Still stuck? Help me debug a SyntaxError or missing output when running inventory.py

Test automatic equality

With a regular class, two objects with the same data are not equal by default. Python compares their memory addresses instead. Dataclasses fix this by generating __eq__ automatically.

  • In inventory.py, add the following code at the bottom of your __main__ block, below the print(f"New: {new}") line:
    # Test equality
    p1 = Product("Widget", 9.99, 100)
    p2 = Product("Widget", 9.99, 100)
    print(f"\np1 == p2: {p1 == p2}")

What does this code do?

It creates two separate Product instances with identical field values. Because @dataclass generates __eq__ for you, Python compares the field values instead of memory addresses. The result is True.

  • Save inventory.py and run it again:
python inventory.py

You should now see the original comparison output followed by p1 == p2: True on a new line.

Seeing p1 == p2: False?

  • Double check that both p1 and p2 use the Product dataclass, not ProductOld.
  • Make sure the values match exactly between both instances (same string, same numbers).

Need more help? Why is my dataclass equality comparison returning False?

✔️ Awesome, I've got everything!

Great. Double check you've saved inventory.py and confirmed the equality test prints True.

ⓧ I'd like to double check the full code

Here is the complete inventory.py file at this point:

from dataclasses import dataclass, field, asdict
import json


# Step 2: The boilerplate problem - regular class
class ProductOld:
    def __init__(self, name: str, price: float, quantity: int):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return f"ProductOld(name='{self.name}', price={self.price}, quantity={self.quantity})"

    def __eq__(self, other):
        if not isinstance(other, ProductOld):
            return NotImplemented
        return (self.name == other.name and
                self.price == other.price and
                self.quantity == other.quantity)


# Step 2 & 3: The dataclass solution
@dataclass
class Product:
    name: str
    price: float
    quantity: int


if __name__ == "__main__":
    # Step 2: Compare old vs new
    print("=== Regular Class vs Dataclass ===")
    old = ProductOld("Widget", 9.99, 100)
    new = Product("Widget", 9.99, 100)
    print(f"Old: {old}")
    print(f"New: {new}")

    # Test equality
    p1 = Product("Widget", 9.99, 100)
    p2 = Product("Widget", 9.99, 100)
    print(f"\np1 == p2: {p1 == p2}")

You just turned 15 lines of repetitive code into 5 clean lines. Next up, you'll add default values and learn how field() gives you fine-grained control over each field's behavior.

Add Default Values and Use field()

Your Product dataclass works, but every product right now requires exactly three arguments. Real products have optional attributes like categories, tags, and internal IDs that you want to control more precisely.

Python's field() function gives you fine-grained control over each field in a dataclass. In this step, you will add default values, handle mutable defaults safely, and hide fields from print output.

In this step, get ready to:
  • Add default values and field() controls to your Product dataclass.
  • Test that mutable defaults are safely independent between instances.
  • See the error Python raises when you skip default_factory.
Add default values and field() to your Product dataclass

Not every field needs a value at creation time. Some fields should have sensible defaults, some should be hidden from output, and some need special handling because they hold mutable data like lists.

  • In inventory.py, find the Product dataclass.
  • Find this code:
@dataclass
class Product:
    name: str
    price: float
    quantity: int
  • Replace the entire Product class with this updated version that adds three new fields:
# Step 2 & 3: The dataclass solution
@dataclass
class Product:
    name: str
    price: float
    quantity: int
    category: str = "general"
    tags: list = field(default_factory=list)
    sku: str = field(default="", repr=False)

What does this code do?

  • category: str = "general" gives every product a default category. If you do not pass one, it becomes "general" automatically.
  • tags: list = field(default_factory=list) creates a fresh empty list for each product instance. This prevents all products from accidentally sharing the same list.
  • sku: str = field(default="", repr=False) adds a stock-keeping unit ID that defaults to empty. The repr=False means it will not appear when you print the product.

Why use default_factory for lists?

Lists are mutable objects. If you used a plain = [] as the default, Python would share that single list between every instance. One product's tags would leak into another's.

default_factory=list calls list() fresh for each new instance, guaranteeing independence.

  • Save inventory.py.

You will not see a change yet. The next substep adds test code that exercises these new fields.

Seeing a TypeError about field ordering?

Fields with defaults must come after fields without defaults. Make sure category, tags, and sku are listed below name, price, and quantity.

help me fix a field ordering error in my dataclass

Create products and test field behavior

Now you will create products that use these new fields and confirm that defaults work correctly, tags stay independent between instances, and the SKU stays hidden from print output.

  • In inventory.py, scroll to the bottom of the if __name__ == "__main__": block.
  • Add this test code below the line that says print(f"\np1 == p2: {p1 == p2}"):
    # Step 3: Defaults and field()
    print("\n=== Defaults and field() ===")
    p3 = Product("Gadget", 19.99, 50, category="electronics", sku="GAD-001")
    p3.tags.append("new")
    p3.tags.append("popular")
    p4 = Product("Gizmo", 14.99, 75)
    print(f"p3: {p3}")
    print(f"p3.sku: {p3.sku}")
    print(f"p4.tags: {p4.tags}")

What does this test code do?

  • p3 is created with an explicit category and SKU. You then append two tags to its tags list.
  • p4 is created with only the required fields. It gets the default category "general" and its own empty tags list.
  • Printing p3 should show all fields except the SKU (because of repr=False). Printing p3.sku directly proves the value is still stored.
  • Printing p4.tags should show an empty list, proving it did not inherit p3's tags.
  • Save inventory.py.
  • Run your script by entering this command in the terminal:
python inventory.py
  • Look at the output under === Defaults and field() ===. You should see p3 printed without any SKU in the representation, p3.sku showing GAD-001, and p4.tags showing an empty list [].

Seeing p4.tags with items in it?

If p4.tags shows ["new", "popular"] instead of an empty list, your tags field might be using = [] instead of field(default_factory=list). Make sure it matches the code above exactly.

help me fix shared tags between my Product instances

✔️ Awesome, I've got everything!

Great. Double check you have saved inventory.py and your output shows independent tags and a hidden SKU.

ⓧ I'd like to double check the full code

from dataclasses import dataclass, field, asdict
import json


# Step 2: The boilerplate problem - regular class
class ProductOld:
    def __init__(self, name: str, price: float, quantity: int):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return f"ProductOld(name='{self.name}', price={self.price}, quantity={self.quantity})"

    def __eq__(self, other):
        if not isinstance(other, ProductOld):
            return NotImplemented
        return (self.name == other.name and
                self.price == other.price and
                self.quantity == other.quantity)


# Step 2 & 3: The dataclass solution
@dataclass
class Product:
    name: str
    price: float
    quantity: int
    category: str = "general"
    tags: list = field(default_factory=list)
    sku: str = field(default="", repr=False)


if __name__ == "__main__":
    # Step 2: Compare old vs new
    print("=== Regular Class vs Dataclass ===")
    old = ProductOld("Widget", 9.99, 100)
    new = Product("Widget", 9.99, 100)
    print(f"Old: {old}")
    print(f"New: {new}")

    # Test equality
    p1 = Product("Widget", 9.99, 100)
    p2 = Product("Widget", 9.99, 100)
    print(f"\np1 == p2: {p1 == p2}")

    # Step 3: Defaults and field()
    print("\n=== Defaults and field() ===")
    p3 = Product("Gadget", 19.99, 50, category="electronics", sku="GAD-001")
    p3.tags.append("new")
    p3.tags.append("popular")
    p4 = Product("Gizmo", 14.99, 75)
    print(f"p3: {p3}")
    print(f"p3.sku: {p3.sku}")
    print(f"p4.tags: {p4.tags}")
See why default_factory is necessary

You used field(default_factory=list) for the tags field. What happens if you skip that and use a plain = [] instead? Python actually prevents you from making this mistake.

  • In inventory.py, find this line in the Product class:
    tags: list = field(default_factory=list)
  • Temporarily change it to use a plain list default:
    tags: list = []
  • Save the file and run the script again:
python inventory.py

Python raises a ValueError telling you that a mutable default for the field is not allowed and to use default_factory instead. Python catches this mistake at class definition time so you never accidentally share a list between instances.

Why does Python block this?

In regular classes, using a mutable default like [] or {} as a default argument is a notorious Python bug. Every instance ends up sharing the same list object, causing data to leak between them.

Dataclasses refuse to let you make this mistake. The ValueError forces you to use default_factory, which calls the factory function fresh for every new instance.

  • Change the line back to use field(default_factory=list):
    tags: list = field(default_factory=list)
  • Save inventory.py and run it one more time to confirm the error is gone and your output is back to normal.

You now have a Product dataclass with smart defaults and fine-grained field control. Next up, you will create computed fields that calculate their own values automatically when an object is created.

Compute Fields with __post_init__

Your Product dataclass now handles defaults and controls which fields show up in its representation. But what about fields whose values depend on other fields?

An inventory tracker needs to compute a total value by multiplying price by quantity. You could calculate this every time you need it, but a better approach is to let the dataclass compute it automatically when an instance is created. That is exactly what __post_init__ does.

In this step, get ready to:
  • Create an InventoryItem dataclass with a computed field that calculates itself.
  • Add a restock method that updates the quantity and recalculates the total.
  • Run the script to verify computed values update correctly.
Create the InventoryItem dataclass

Sometimes a field's value is not something you pass in. It is something you compute from other fields. Python's field(init=False) tells the dataclass "do not accept this in the constructor" while __post_init__ gives you a place to calculate and assign that value right after construction.

  • In inventory.py, find the line that says if __name__ == "__main__": and add the following code above it:
# Step 4: Computed fields with __post_init__
@dataclass
class InventoryItem:
    product: Product
    quantity: int
    total_value: float = field(init=False)

    def __post_init__(self):
        self.total_value = self.product.price * self.quantity

What does this code do?

  • product: Product accepts an entire Product instance as a field. Dataclasses can nest inside each other.
  • total_value: float = field(init=False) means this field will NOT appear in the constructor. You cannot pass it in when creating an InventoryItem.
  • __post_init__ runs automatically right after the generated __init__ finishes. It computes total_value by multiplying the product's price by the quantity.

You will not see a change yet. The next chunk adds the test code that creates an InventoryItem and prints the computed value.

Add a restock method

Computed fields have a catch. If you change one of the inputs (like quantity), the computed field does not update on its own. You need a method that recalculates it.

  • Inside the InventoryItem class, add this method below __post_init__:
    def restock(self, amount: int):
        self.quantity += amount
        self.total_value = self.product.price * self.quantity

What does this method do?

restock increases the quantity, then recalculates total_value using the same formula from __post_init__. Without this manual recalculation, total_value would still reflect the old quantity.

You will not see a change yet. The next chunk adds the test code to exercise both the computed field and the restock method.

Test computed fields and restocking
  • Inside the if __name__ == "__main__": block, add the following code at the very bottom (after the p4.tags print line):
    # Step 4: Computed fields
    print("\n=== Computed Fields ===")
    item = InventoryItem(product=p3, quantity=50)
    print(f"Item: {item}")
    print(f"Total value: ${item.total_value:.2f}")
    item.restock(25)
    print(f"After restock: quantity={item.quantity}, total=${item.total_value:.2f}")

What does this test code do?

  • InventoryItem(product=p3, quantity=50) creates an inventory item using the p3 Gadget product from the previous step. Notice you do NOT pass total_value. It computes itself.
  • item.restock(25) bumps quantity from 50 to 75 and recalculates the total from $999.50 to $1499.25.
  • Save inventory.py.
  • Run the script by entering this command in your terminal:
python inventory.py
  • In the output, look for the === Computed Fields === section. You should see the InventoryItem printed with a total_value of $999.50, then after restocking, the quantity jumps to 75 and the total becomes $1499.25.

Seeing a NameError or wrong total?

  • If you see NameError: name 'InventoryItem' is not defined, make sure the InventoryItem class is placed ABOVE the if __name__ == "__main__": block, not inside it.
  • If the total value is wrong, check that __post_init__ uses self.product.price (not self.price). The price lives on the nested Product object, not directly on InventoryItem.
  • Make sure the indentation of restock is at the same level as __post_init__. Both methods should be indented one level inside the class.

Help me debug my InventoryItem dataclass

✔️ Awesome, I've got everything!

Great. Double check you have saved inventory.py before moving on.

ⓧ I'd like to double check the full code

Here is the full inventory.py file as it should look at the end of this step:

from dataclasses import dataclass, field, asdict
import json


# Step 2: The boilerplate problem - regular class
class ProductOld:
    def __init__(self, name: str, price: float, quantity: int):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return f"ProductOld(name='{self.name}', price={self.price}, quantity={self.quantity})"

    def __eq__(self, other):
        if not isinstance(other, ProductOld):
            return NotImplemented
        return (self.name == other.name and
                self.price == other.price and
                self.quantity == other.quantity)


# Step 2 & 3: The dataclass solution
@dataclass
class Product:
    name: str
    price: float
    quantity: int
    category: str = "general"
    tags: list = field(default_factory=list)
    sku: str = field(default="", repr=False)


# Step 4: Computed fields with __post_init__
@dataclass
class InventoryItem:
    product: Product
    quantity: int
    total_value: float = field(init=False)

    def __post_init__(self):
        self.total_value = self.product.price * self.quantity

    def restock(self, amount: int):
        self.quantity += amount
        self.total_value = self.product.price * self.quantity


if __name__ == "__main__":
    # Step 2: Compare old vs new
    print("=== Regular Class vs Dataclass ===")
    old = ProductOld("Widget", 9.99, 100)
    new = Product("Widget", 9.99, 100)
    print(f"Old: {old}")
    print(f"New: {new}")

    # Test equality
    p1 = Product("Widget", 9.99, 100)
    p2 = Product("Widget", 9.99, 100)
    print(f"\np1 == p2: {p1 == p2}")

    # Step 3: Defaults and field()
    print("\n=== Defaults and field() ===")
    p3 = Product("Gadget", 19.99, 50, category="electronics", sku="GAD-001")
    p3.tags.append("new")
    p3.tags.append("popular")
    p4 = Product("Gizmo", 14.99, 75)
    print(f"p3: {p3}")
    print(f"p3.sku: {p3.sku}")
    print(f"p4.tags: {p4.tags}")

    # Step 4: Computed fields
    print("\n=== Computed Fields ===")
    item = InventoryItem(product=p3, quantity=50)
    print(f"Item: {item}")
    print(f"Total value: ${item.total_value:.2f}")
    item.restock(25)
    print(f"After restock: quantity={item.quantity}, total=${item.total_value:.2f}")

Your inventory items now compute their own totals and stay in sync when quantities change. Next up, you will create price records that can never be modified after creation using frozen dataclasses.

Create Immutable Records with frozen=True

Your inventory tracker can now compute values automatically. But what about data that should never change after creation?

Think about a price history log. Once you record that a widget cost $9.99 on a specific date, that record is a fact. Changing it would corrupt your data. In this step, you will create immutable dataclass instances that reject any modification after creation.

In this step, get ready to:
  • Create a frozen dataclass that cannot be modified after creation.
  • Observe the error Python raises when you try to modify a frozen instance.
  • Use frozen dataclass instances as dictionary keys and set members.
Create the PriceRecord frozen dataclass

A frozen dataclass uses @dataclass(frozen=True) to make instances completely immutable. Any attempt to change a field after creation raises an error.

  • In inventory.py, find the InventoryItem class (with its restock method).
  • Add the following code below the InventoryItem class, before the if __name__ == "__main__": block:
# Step 5: Immutable records with frozen=True
@dataclass(frozen=True)
class PriceRecord:
    product_name: str
    price: float
    recorded_at: str

What does this code do?

The frozen=True parameter tells Python to generate special __setattr__ and __delattr__ methods that block any modification after creation.

This is perfect for records that represent historical facts. A price record should never change once it has been captured.

  • Save inventory.py.

You will not see a change yet. The next chunk uses this class to test immutability.

Try to modify a frozen record

To prove that frozen records reject changes, you will create a PriceRecord and deliberately try to modify it. Python will raise a FrozenInstanceError (a subclass of AttributeError).

  • Scroll down to the if __name__ == "__main__": block.
  • Find the last line that says print(f"After restock: quantity={item.quantity}, total=${item.total_value:.2f}").
  • Add the following code below it:
    # Step 5: Frozen records
    print("\n=== Frozen Records ===")
    record = PriceRecord("Widget", 9.99, "2026-05-18T10:00:00")
    print(f"Record: {record}")
    try:
        record.price = 5.99
    except AttributeError as e:
        print(f"Cannot modify frozen record: {e}")

What does this code do?

  • Creates a PriceRecord representing a Widget priced at $9.99 on a specific date.
  • The try/except block attempts to set record.price = 5.99. Because the dataclass is frozen, Python raises a FrozenInstanceError which we catch as AttributeError (its parent class).
  • Save inventory.py.
  • Run your script by entering this command in the terminal:
python inventory.py

In the === Frozen Records === section of the output, you should see the record printed, followed by a message saying it cannot be modified.

Seeing a NameError for PriceRecord?

  • Make sure the PriceRecord class is defined above the if __name__ == "__main__": block, not inside it.
  • Check that the indentation of @dataclass(frozen=True) is at the top level (no leading spaces).

Why is my PriceRecord not being recognized?

Use frozen records as dictionary keys and set members

Regular dataclasses are not hashable, which means you cannot use them as dictionary keys or add them to sets. Frozen dataclasses are automatically hashable because their values can never change.

  • In inventory.py, find the except AttributeError as e: block you just added.
  • Add the following code below the print(f"Cannot modify frozen record: {e}") line:
    # Use as dict key and in a set
    price_history = {record: "initial price"}
    price_set = {record, PriceRecord("Gadget", 19.99, "2026-05-18T11:00:00")}
    print(f"Price set: {price_set}")

Why can frozen records be dict keys?

Python requires dictionary keys and set members to be hashable. An object is hashable only if its value never changes after creation.

Because frozen=True guarantees immutability, Python automatically generates a __hash__ method for frozen dataclasses. This makes them usable anywhere a dictionary key or set element is needed.

  • Save inventory.py and run your script again:
python inventory.py

At the bottom of the === Frozen Records === section, you should see a set containing two PriceRecord instances printed out.

Seeing a TypeError about unhashable type?

  • Double-check that your PriceRecord class uses @dataclass(frozen=True) and not just @dataclass. Without frozen=True, the instance is not hashable.
  • Make sure you did not accidentally remove the (frozen=True) part when editing.

Why is my frozen dataclass not hashable?

✔️ Awesome, I've got everything!

Great. Double check you have saved inventory.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete inventory.py file at this point.

from dataclasses import dataclass, field, asdict
import json


# Step 2: The boilerplate problem - regular class
class ProductOld:
    def __init__(self, name: str, price: float, quantity: int):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return f"ProductOld(name='{self.name}', price={self.price}, quantity={self.quantity})"

    def __eq__(self, other):
        if not isinstance(other, ProductOld):
            return NotImplemented
        return (self.name == other.name and
                self.price == other.price and
                self.quantity == other.quantity)


# Step 2 & 3: The dataclass solution
@dataclass
class Product:
    name: str
    price: float
    quantity: int
    category: str = "general"
    tags: list = field(default_factory=list)
    sku: str = field(default="", repr=False)


# Step 4: Computed fields with __post_init__
@dataclass
class InventoryItem:
    product: Product
    quantity: int
    total_value: float = field(init=False)

    def __post_init__(self):
        self.total_value = self.product.price * self.quantity

    def restock(self, amount: int):
        self.quantity += amount
        self.total_value = self.product.price * self.quantity


# Step 5: Immutable records with frozen=True
@dataclass(frozen=True)
class PriceRecord:
    product_name: str
    price: float
    recorded_at: str


if __name__ == "__main__":
    # Step 2: Compare old vs new
    print("=== Regular Class vs Dataclass ===")
    old = ProductOld("Widget", 9.99, 100)
    new = Product("Widget", 9.99, 100)
    print(f"Old: {old}")
    print(f"New: {new}")

    # Test equality
    p1 = Product("Widget", 9.99, 100)
    p2 = Product("Widget", 9.99, 100)
    print(f"\np1 == p2: {p1 == p2}")

    # Step 3: Defaults and field()
    print("\n=== Defaults and field() ===")
    p3 = Product("Gadget", 19.99, 50, category="electronics", sku="GAD-001")
    p3.tags.append("new")
    p3.tags.append("popular")
    p4 = Product("Gizmo", 14.99, 75)
    print(f"p3: {p3}")
    print(f"p3.sku: {p3.sku}")
    print(f"p4.tags: {p4.tags}")

    # Step 4: Computed fields
    print("\n=== Computed Fields ===")
    item = InventoryItem(product=p3, quantity=50)
    print(f"Item: {item}")
    print(f"Total value: ${item.total_value:.2f}")
    item.restock(25)
    print(f"After restock: quantity={item.quantity}, total=${item.total_value:.2f}")

    # Step 5: Frozen records
    print("\n=== Frozen Records ===")
    record = PriceRecord("Widget", 9.99, "2026-05-18T10:00:00")
    print(f"Record: {record}")
    try:
        record.price = 5.99
    except AttributeError as e:
        print(f"Cannot modify frozen record: {e}")

    # Use as dict key and in a set
    price_history = {record: "initial price"}
    price_set = {record, PriceRecord("Gadget", 19.99, "2026-05-18T11:00:00")}
    print(f"Price set: {price_set}")

Your inventory tracker now has immutable price records that are safe from accidental modification. Next up, you will make your inventory sortable so you can rank items by stock level.

Sort Your Inventory with order=True

Your frozen price records proved that dataclasses can lock down data. Now you need a way to quickly rank your inventory by stock level.

Python's sorted() function works on any object that supports comparison operators like < and >. In this step, you will use order=True to generate those comparison methods automatically on a dataclass.

In this step, get ready to:
  • Create a sortable StockLevel dataclass with order=True.
  • Sort a list of stock levels using Python's built-in sorted().
  • Find the lowest and highest stock items using min() and max().
Create the StockLevel dataclass

When you pass order=True to the @dataclass decorator, Python generates comparison methods that compare instances field by field, in the order the fields are defined. This means the first field becomes the primary sort key.

  • In inventory.py, find the PriceRecord class. Add the following code below it, above the if __name__ == "__main__": line:
# Step 6: Sortable dataclass with order=True
@dataclass(order=True)
class StockLevel:
    quantity: int
    product_name: str

What does this code do?

order=True generates __lt__, __le__, __gt__, and __ge__ methods. These let Python compare two StockLevel instances like tuples of their fields.

Because quantity is defined first, it becomes the primary sort key. If two items have the same quantity, Python falls through to product_name as the tiebreaker.

  • Save inventory.py.
  • You won't see a change yet. The next substep uses this class to sort a list.
Sort your stock levels

With comparison methods generated, you can pass StockLevel instances to any function that relies on ordering.

  • Scroll to the bottom of the if __name__ == "__main__": block, after the price_set print statement.
  • Add the following code:
    # Step 6: Ordering
    print("\n=== Ordering ===")
    stock_levels = [
        StockLevel(50, "Widget"),
        StockLevel(10, "Gadget"),
        StockLevel(200, "Gizmo"),
        StockLevel(5, "Thingamajig"),
    ]
    sorted_stock = sorted(stock_levels)
    print("Sorted by quantity:")
    for s in sorted_stock:
        print(f"  {s.product_name}: {s.quantity}")
    print(f"Lowest stock: {min(stock_levels)}")
    print(f"Highest stock: {max(stock_levels)}")

What does this code do?

  • stock_levels creates a list of four StockLevel instances with different quantities.
  • sorted(stock_levels) returns a new list ranked from lowest to highest quantity because quantity is the first field.
  • min() and max() also rely on the comparison methods that order=True generated. They return the single smallest and largest items.

Why does field order matter?

Python compares ordered dataclasses the same way it compares tuples. A StockLevel(10, "Gadget") is treated like the tuple (10, "Gadget") during comparisons. If you swapped the field order to put product_name first, your inventory would sort alphabetically instead of by quantity.

Run and verify the sorted output
  • Save inventory.py.
  • Run your script by entering this command in the terminal:
python inventory.py
  • Scroll to the === Ordering === section in the output. You should see Thingamajig (5) listed first, then Gadget (10), Widget (50), and Gizmo (200) last.
  • Below the sorted list, confirm you see the lowest stock item (Thingamajig) and the highest stock item (Gizmo).

Seeing a TypeError about ordering?

  • Make sure your StockLevel class uses @dataclass(order=True), not just @dataclass. Without order=True, Python does not generate comparison methods.
  • Check that StockLevel is defined above the if __name__ == "__main__": block. Python reads top to bottom, so the class must exist before you use it.

Why is my StockLevel not sorting correctly?

✔️ Awesome, I've got everything!

Great. Double check you have saved inventory.py before moving on.

ⓧ I'd like to double check the full code

Here is the full inventory.py file after completing this step:

from dataclasses import dataclass, field, asdict
import json


# Step 2: The boilerplate problem - regular class
class ProductOld:
    def __init__(self, name: str, price: float, quantity: int):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return f"ProductOld(name='{self.name}', price={self.price}, quantity={self.quantity})"

    def __eq__(self, other):
        if not isinstance(other, ProductOld):
            return NotImplemented
        return (self.name == other.name and
                self.price == other.price and
                self.quantity == other.quantity)


# Step 2 & 3: The dataclass solution
@dataclass
class Product:
    name: str
    price: float
    quantity: int
    category: str = "general"
    tags: list = field(default_factory=list)
    sku: str = field(default="", repr=False)


# Step 4: Computed fields with __post_init__
@dataclass
class InventoryItem:
    product: Product
    quantity: int
    total_value: float = field(init=False)

    def __post_init__(self):
        self.total_value = self.product.price * self.quantity

    def restock(self, amount: int):
        self.quantity += amount
        self.total_value = self.product.price * self.quantity


# Step 5: Immutable records with frozen=True
@dataclass(frozen=True)
class PriceRecord:
    product_name: str
    price: float
    recorded_at: str


# Step 6: Sortable dataclass with order=True
@dataclass(order=True)
class StockLevel:
    quantity: int
    product_name: str


if __name__ == "__main__":
    # Step 2: Compare old vs new
    print("=== Regular Class vs Dataclass ===")
    old = ProductOld("Widget", 9.99, 100)
    new = Product("Widget", 9.99, 100)
    print(f"Old: {old}")
    print(f"New: {new}")

    # Test equality
    p1 = Product("Widget", 9.99, 100)
    p2 = Product("Widget", 9.99, 100)
    print(f"\np1 == p2: {p1 == p2}")

    # Step 3: Defaults and field()
    print("\n=== Defaults and field() ===")
    p3 = Product("Gadget", 19.99, 50, category="electronics", sku="GAD-001")
    p3.tags.append("new")
    p3.tags.append("popular")
    p4 = Product("Gizmo", 14.99, 75)
    print(f"p3: {p3}")
    print(f"p3.sku: {p3.sku}")
    print(f"p4.tags: {p4.tags}")

    # Step 4: Computed fields
    print("\n=== Computed Fields ===")
    item = InventoryItem(product=p3, quantity=50)
    print(f"Item: {item}")
    print(f"Total value: ${item.total_value:.2f}")
    item.restock(25)
    print(f"After restock: quantity={item.quantity}, total=${item.total_value:.2f}")

    # Step 5: Frozen records
    print("\n=== Frozen Records ===")
    record = PriceRecord("Widget", 9.99, "2026-05-18T10:00:00")
    print(f"Record: {record}")
    try:
        record.price = 5.99
    except AttributeError as e:
        print(f"Cannot modify frozen record: {e}")

    # Use as dict key and in a set
    price_history = {record: "initial price"}
    price_set = {record, PriceRecord("Gadget", 19.99, "2026-05-18T11:00:00")}
    print(f"Price set: {price_set}")

    # Step 6: Ordering
    print("\n=== Ordering ===")
    stock_levels = [
        StockLevel(50, "Widget"),
        StockLevel(10, "Gadget"),
        StockLevel(200, "Gizmo"),
        StockLevel(5, "Thingamajig"),
    ]
    sorted_stock = sorted(stock_levels)
    print("Sorted by quantity:")
    for s in sorted_stock:
        print(f"  {s.product_name}: {s.quantity}")
    print(f"Lowest stock: {min(stock_levels)}")
    print(f"Highest stock: {max(stock_levels)}")

Your inventory items now sort themselves automatically. Next up, you will export your entire inventory to JSON using asdict() so your data can travel outside Python.

Export to JSON with asdict()

Your inventory tracker can now sort items by stock level. That's powerful for internal logic, but what about sharing data with other systems?

Most APIs, databases, and frontend apps communicate using JSON. In this step, you'll use asdict() from the dataclasses module to convert your dataclass instances into plain dictionaries, then serialize them to a JSON file.

In this step, get ready to:
  • Write an export function using asdict() and json.dumps().
  • Create a product list and export it as formatted JSON.
  • Write the JSON output to a file called inventory_export.json.
Write the export function

The asdict() function takes a dataclass instance and returns a plain Python dictionary with all its field names as keys and their values as values. This makes it trivial to convert structured dataclass objects into JSON-ready data.

  • In inventory.py, find the StockLevel class. Add the following function below it, above the if __name__ == "__main__": line:
# Step 7: JSON export
def export_inventory(products: list) -> str:
    # Convert each product dataclass into a plain dictionary
    product_dicts = [asdict(p) for p in products]
    # Serialize the list of dicts to a formatted JSON string
    return json.dumps(product_dicts, indent=2)

What does this code do?

asdict(p) recursively converts a dataclass instance into a dictionary. Every field becomes a key-value pair.

json.dumps() serializes that dictionary into a JSON string. The indent=2 argument formats it with readable indentation instead of cramming everything onto one line.

  • Save inventory.py.

Why asdict() instead of manual conversion?

You could loop through fields manually, but asdict() handles nested dataclasses, lists, and dicts automatically. If your Product contained another dataclass as a field, asdict() would recursively convert that too.

Create products and test the export

Now you'll create a list of products and run them through your export function to see the JSON output.

  • Scroll to the bottom of the if __name__ == "__main__": block (after the Step 6 ordering section). Add the following code:
    # Step 7: JSON export
    print("\n=== JSON Export ===")
    products = [
        Product("Widget", 9.99, 100, category="hardware"),
        Product("Gadget", 19.99, 50, category="electronics"),
        Product("Gizmo", 14.99, 75, category="accessories"),
    ]
    json_output = export_inventory(products)
    print(json_output)

What does this code do?

This creates three Product instances with different categories, passes them to export_inventory(), and prints the formatted JSON string that comes back.

  • Save inventory.py and run the script by entering this command in your terminal:
python inventory.py
  • Scroll to the bottom of the terminal output. You should see a formatted JSON array with three product objects, each containing name, price, quantity, category, tags, and sku fields.

Not seeing JSON output?

Check that the export_inventory function is defined above the if __name__ == "__main__": line, not inside it. Functions defined inside the main block won't be accessible at the point you call them.

Also confirm that the indentation of your new code inside the main block uses 4 spaces (matching the rest of the main block).

Why isn't my export_inventory function producing JSON output?

Write the JSON to a file

Printing JSON to the terminal is useful for debugging. Writing it to a file makes it portable. You can send that file to another system, load it into a database, or use it as input for a frontend app.

  • In inventory.py, add the following code directly below the print(json_output) line you just added:
    with open("inventory_export.json", "w") as f:
        f.write(json_output)
    print("\nExported to inventory_export.json")

What does this code do?

open("inventory_export.json", "w") creates (or overwrites) a file in your project folder. The "w" mode means "write".

The with statement ensures the file is properly closed after writing, even if an error occurs. This is Python's recommended pattern for file handling.

  • Save inventory.py and run the script again:
python inventory.py
  • You should see Exported to inventory_export.json at the end of the output.
  • Confirm the file was created by running:
ls
  • You should see inventory_export.json listed alongside inventory.py.
  • Open inventory_export.json in the VS Code Explorer sidebar to verify it contains your three products formatted as JSON.

Don't see inventory_export.json?

Make sure you saved inventory.py before running it. Unsaved changes won't execute.

Check that the file write code is indented inside the if __name__ == "__main__": block (4 spaces). If it's at the top level, it will still run, but confirm your terminal's working directory is the inventory-tracker folder.

Why isn't my inventory_export.json file being created?

✔️ Awesome, I've got everything!

Double check you've saved inventory.py and confirmed inventory_export.json exists in your project folder.

ⓧ I'd like to double check the full code

from dataclasses import dataclass, field, asdict
import json


# Step 2: The boilerplate problem - regular class
class ProductOld:
    def __init__(self, name: str, price: float, quantity: int):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return f"ProductOld(name='{self.name}', price={self.price}, quantity={self.quantity})"

    def __eq__(self, other):
        if not isinstance(other, ProductOld):
            return NotImplemented
        return (self.name == other.name and
                self.price == other.price and
                self.quantity == other.quantity)


# Step 2 & 3: The dataclass solution
@dataclass
class Product:
    name: str
    price: float
    quantity: int
    category: str = "general"
    tags: list = field(default_factory=list)
    sku: str = field(default="", repr=False)


# Step 4: Computed fields with __post_init__
@dataclass
class InventoryItem:
    product: Product
    quantity: int
    total_value: float = field(init=False)

    def __post_init__(self):
        self.total_value = self.product.price * self.quantity

    def restock(self, amount: int):
        self.quantity += amount
        self.total_value = self.product.price * self.quantity


# Step 5: Immutable records with frozen=True
@dataclass(frozen=True)
class PriceRecord:
    product_name: str
    price: float
    recorded_at: str


# Step 6: Sortable dataclass with order=True
@dataclass(order=True)
class StockLevel:
    quantity: int
    product_name: str


# Step 7: JSON export
def export_inventory(products: list) -> str:
    product_dicts = [asdict(p) for p in products]
    return json.dumps(product_dicts, indent=2)


if __name__ == "__main__":
    # Step 2: Compare old vs new
    print("=== Regular Class vs Dataclass ===")
    old = ProductOld("Widget", 9.99, 100)
    new = Product("Widget", 9.99, 100)
    print(f"Old: {old}")
    print(f"New: {new}")

    # Test equality
    p1 = Product("Widget", 9.99, 100)
    p2 = Product("Widget", 9.99, 100)
    print(f"\np1 == p2: {p1 == p2}")

    # Step 3: Defaults and field()
    print("\n=== Defaults and field() ===")
    p3 = Product("Gadget", 19.99, 50, category="electronics", sku="GAD-001")
    p3.tags.append("new")
    p3.tags.append("popular")
    p4 = Product("Gizmo", 14.99, 75)
    print(f"p3: {p3}")
    print(f"p3.sku: {p3.sku}")
    print(f"p4.tags: {p4.tags}")

    # Step 4: Computed fields
    print("\n=== Computed Fields ===")
    item = InventoryItem(product=p3, quantity=50)
    print(f"Item: {item}")
    print(f"Total value: ${item.total_value:.2f}")
    item.restock(25)
    print(f"After restock: quantity={item.quantity}, total=${item.total_value:.2f}")

    # Step 5: Frozen records
    print("\n=== Frozen Records ===")
    record = PriceRecord("Widget", 9.99, "2026-05-18T10:00:00")
    print(f"Record: {record}")
    try:
        record.price = 5.99
    except AttributeError as e:
        print(f"Cannot modify frozen record: {e}")

    # Use as dict key and in a set
    price_history = {record: "initial price"}
    price_set = {record, PriceRecord("Gadget", 19.99, "2026-05-18T11:00:00")}
    print(f"Price set: {price_set}")

    # Step 6: Ordering
    print("\n=== Ordering ===")
    stock_levels = [
        StockLevel(50, "Widget"),
        StockLevel(10, "Gadget"),
        StockLevel(200, "Gizmo"),
        StockLevel(5, "Thingamajig"),
    ]
    sorted_stock = sorted(stock_levels)
    print("Sorted by quantity:")
    for s in sorted_stock:
        print(f"  {s.product_name}: {s.quantity}")
    print(f"Lowest stock: {min(stock_levels)}")
    print(f"Highest stock: {max(stock_levels)}")

    # Step 7: JSON export
    print("\n=== JSON Export ===")
    products = [
        Product("Widget", 9.99, 100, category="hardware"),
        Product("Gadget", 19.99, 50, category="electronics"),
        Product("Gizmo", 14.99, 75, category="accessories"),
    ]
    json_output = export_inventory(products)
    print(json_output)

    with open("inventory_export.json", "w") as f:
        f.write(json_output)
    print("\nExported to inventory_export.json")

Your inventory tracker is complete. You've gone from a boilerplate-heavy regular class all the way to exporting structured data as JSON. Ready for a challenge? The Secret Mission will push your dataclass skills further with a discount system.

Secret mission

Add a Discount System

Your inventory tracker works great, but real stores need discounts. Can you add a discount system that uses field(compare=False) to keep discounts from affecting product equality?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The inventory-tracker project folder (contains inventory.py and inventory_export.json).
  • Python installation.
  • VS Code with the Python extension.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with more dataclass experiments.

  • Your inventory-tracker folder stays on your Desktop ready for future work.
  • Python and VS Code remain installed for your next project.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files sit quietly on disk until you return.

  • Keep your inventory-tracker folder where it is.
  • Keep Python and VS Code installed so you can pick up where you left off.

Delete - I don't want to use this again

Remove all project files and optionally uninstall the tools you set up.

  • Delete the project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/inventory-tracker

Windows

Remove-Item -Recurse -Force ~\Desktop\inventory-tracker
  • To uninstall Python on Windows, open Settings, select Apps, then Installed apps. Find Python in the list and click Uninstall.
  • To uninstall Python on macOS, delete the Python framework folder and the symlinks. If you installed via the official installer, you can find its uninstall instructions in the Applications/Python 3.x folder.
  • To uninstall VS Code on Windows, open Settings, select Apps, then Installed apps. Find Visual Studio Code and click Uninstall.
  • To uninstall VS Code on macOS, drag Visual Studio Code from your Applications folder to the Trash.

Nice Work!

Nice Work!

Nice work! You just built a fully functional inventory tracker using Python dataclasses.

You've learned how to:

  • Replaced 15+ lines of boilerplate with a clean @dataclass decorator, getting __init__, __repr__, and __eq__ for free.
  • Controlled field behavior with field() for defaults, representation, and comparison, and computed derived values automatically with __post_init__.
  • Created immutable records with frozen=True, sorted inventory with order=True, and exported structured data to JSON with asdict().
  • Secret Mission: Added a discount system using field(compare=False) and custom methods to separate product identity from metadata.

Ready to quiz yourself?