Classes and the @memo decorator

A memo class is very similar to a regular Python class but comes with several powerful, built-in features. 📝

What is a Memo Class?

A memo class behaves much like a standard Python class but includes these additional properties:

  • Persistence: Instances are automatically saved (persisted) when referenced by other objects or tags, making them accessible later.

  • Managed Storage: Instances are stored efficiently, either on disk or in memory, depending on how frequently they are accessed.

  • Automatic Type Conversion:

    • Simple attributes (like int, str, float, date, bytes) are converted to dbzero types behind the scenes but behave just like standard Python types when you access them.

    • Collection attributes (tuple, list, set, dict) are converted to dbzero collections. They work like regular Python collections but are managed by dbzero.

    • Future Integration: We plan to add support for pandas DataFrames in the near future. 🐼

  • Object Members: Attributes can be other python objects, as long as they're instances of a memo class.

  • UUID: Each instance is automatically assigned a universally unique identifier (UUID), which can be used externally in API clients or web applications to reference the object directly.

  • Tagging: You can apply custom tags to memo instances, allowing for easy searching and retrieval later on.

Now, let's see what a typical memo class looks like in practice.

Declaring Memo Classes

To declare a memo class, simply apply the @dbzero.memo decorator to a standard Python class. Unlike many database frameworks, there's no need to define a separate data model or schema. You assign attributes directly in the constructor (__init__), just as you would with any regular Python object.

import dbzero as db0
 
@db0.memo
class Person:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

Instantiating and Modifying Instances

Instantiating a memo class works exactly like it does for a regular Python class. The key difference is that each new instance is automatically saved (persisted) under the current prefix. (See Understanding Prefixes and Scoped Types to learn more).

Furthermore, memo class instances are fully dynamic. You can add, remove, or change their attributes at any time, just as you would with a standard Python object.

# Instantiation is the same as with a regular class
person_1 = Person("Alice", 30)
person_2 = Person("Bob", 25)
 
# Attributes can be modified
person_1.name = "Alice Smith"
person_2.age += 1
 
# Attributes can be added on the fly
person_2.address = "123 Main St"

Immutable Memo Objects

dbzero-procommercial edition

Immutable memo objects are regular memo objects declared with @db0.memo(immutable=True). They are designed for data that is created once and then expected to remain unchanged, such as audit traces, domain events, append-only logs, historical snapshots, and signed or security-sensitive records.

import dbzero as db0
 
@db0.memo(immutable=True)
class AuditEvent:
    def __init__(self, actor_id: str, action: str, payload: dict):
        self.actor_id = actor_id
        self.action = action
        self.payload = payload
 
event = AuditEvent(
    actor_id="user-123",
    action="invoice.approved",
    payload={"invoice_id": "inv-001", "amount": 3900},
)

Fields are assigned during construction. After the object is created, dbzero can use an optimized immutable layout instead of the regular mutable-object layout. For complex immutable data graphs, this may reduce storage by up to 10-20x because nested immutable values can use embedded storage and a compact object representation.

Immutable objects retain the regular memo feature set: they can be tagged, found by type or tag, referenced by other objects, dropped when the last reference is removed, and addressed by UUID.

db0.tags(event).add("audit", "invoice")
 
event_uuid = db0.uuid(event)
same_event = db0.fetch(event_uuid)
 
assert same_event.action == "invoice.approved"
assert list(db0.find(AuditEvent, "invoice")) == [event]

Immutable memo objects are also useful for nested event payloads. If the nested classes are immutable too, dbzero can store the graph more compactly while keeping normal object access:

@db0.memo(immutable=True)
class PaymentDetails:
    def __init__(self, method: str, provider_ref: str):
        self.method = method
        self.provider_ref = provider_ref
 
@db0.memo(immutable=True)
class PaymentEvent:
    def __init__(self, payment_id: str, details: PaymentDetails):
        self.payment_id = payment_id
        self.details = details
 
payment_event = PaymentEvent(
    payment_id="pay-001",
    details=PaymentDetails("card", "stripe:ch_123"),
)
 
assert payment_event.details.method == "card"

Use immutable memo objects when the application model treats the data as final after creation. This can improve performance and storage utilization, and it helps protect important records from accidental or unauthorized tampering.

Interned Memo Objects

dbzero-procommercial edition

Interned memo objects are content-addressed immutable memo objects. Declare them with @db0.memo(immutable=True, intern=True) when repeated equal values of the same memo class must resolve to one canonical durable dbzero identity instead of being stored as many duplicates.

@db0.memo(immutable=True, intern=True)
class EventLabel:
    def __init__(self, name: str):
        self.name = name
 
first = db0.materialized(EventLabel("invoice"))
second = db0.materialized(EventLabel("invoice"))
 
assert db0.uuid(first) == db0.uuid(second)

intern=True requires immutable=True because the durable object identity is tied to its contents. You can still create multiple temporary Python instances with equal content. Once any of them needs a durable identity, dbzero checks the content index for an existing object of the same memo class with the same normalized content. If one exists, the Python instance resolves to that canonical object. If not, dbzero registers the new object as the canonical value.

Use db0.materialized(...) when you want that canonical standalone object immediately. db0.uuid(...), storing the object as a reference, tagging it, or otherwise forcing durable identity performs the same canonical resolution. Equal interned values of the same memo class are guaranteed to resolve to the same durable identity and the same UUID.

label = db0.materialized(EventLabel("payment"))
label_uuid = db0.uuid(label)
 
event = PaymentEvent(
    payment_id="pay-002",
    details=PaymentDetails("card", "stripe:ch_456"),
)
db0.tags(label).add("event-label")
 
assert db0.fetch(label_uuid) is label

Materialization is not required at construction time. You can pass a fresh interned value into another immutable object and let dbzero materialize it lazily. This lazy path is required when you want dbzero to embed the interned value inside the immutable parent instead of forcing a separate referenced object first.

@db0.memo(immutable=True)
class LabeledEvent:
    def __init__(self, event_id: str, label: EventLabel):
        self.event_id = event_id
        self.label = label
 
# No db0.materialized(...) call here: the label can be embedded.
embedded_event = LabeledEvent("evt-001", EventLabel("invoice"))
 
# Explicit materialization first: the event stores a reference to the canonical label.
canonical_label = db0.materialized(EventLabel("invoice"))
referencing_event = LabeledEvent("evt-002", canonical_label)
 
assert db0.uuid(embedded_event.label) == db0.uuid(canonical_label)
assert referencing_event.label.name == "invoice"

Embedded and referenced interned forms compare by normalized content, so equal values of the same memo class resolve to the same canonical durable value when they are materialized, UUID-ed, referenced, or otherwise given durable identity. Different content remains distinct, and the same content on a different memo class remains distinct. Interned objects may reference other interned immutable objects, but not mutable memo objects or non-intern memo objects. Interning also does not keep an object alive by itself; normal references and tags still control lifetime.

Using Collections as Members

You can use standard Python collections like tuple, list, set, and dict as attributes in your memo classes. When assigned, dbzero automatically converts them into persistent, managed collections that still behave just like their standard Python counterparts.

from datetime import date
import dbzero as db0
 
@db0.memo
class Person:
    def __init__(self):
        # A tuple of strings
        self.name = ("Alice", "Smith", "Mrs")
        
        # A list of date objects
        self.important_dates = [date(2023, 1, 1), date(2023, 12, 31)]
        
        # A set of strings
        self.favorite_animals = {"cat", "dog", "rabbit"}
        
        # A dictionary
        self.addresses = {"home": "123 Main St", "work": "456 Elm St"}

Referencing Other Memo Objects

You can assign memo objects as attributes. This lets you build complex, nested object graphs. The number of nested objects, their size, and their dependencies are limited only by available storage.

Memo objects are reference-counted. An object will automatically persist as long as at least one other object holds a reference to it. When the last reference is removed, the object qualifies for garbage-collection.

For example, a Pet object can hold a reference to a Person object.

@db0.memo
class Pet:
    def __init__(self, name: str, owner: Person = None):
        self.name = name
        self.owner = owner
 
cat = Pet("Whiskers", Person("Bob", 30))

Using Memo Objects in Python Collections

You can store memo objects in standard Python collections, such as list or dict, just like any other Python object. A collection holds references to its items. Therefore, as long as a memo object is part of a collection, it is referenced and will persist. The objects inside may be garbage-collected only after the collection itself goes out of scope, assuming no other references to them exist.

In this example, three Pet objects are created and stored in a standard Python list. They will persist at least as long as the bob_cats list exists in memory or is referenced by other dbzero-managed object.

# Assuming 'Person' and 'Pet' are @memo classes
bob = Person(name="Bob", age=30, pets=None)
 
# The Pet objects are stored in a regular Python list
bob_cats = [Pet(name, bob) for name in ["Whiskers", "Garfield", "Mittens"]]
bob.pets = bob_cats

Modifying Memo Objects

You can modify memo objects at any time after they're created. All changes are persistent, meaning they are saved to the durable persistent automatically.

Memo objects are flexible, just like regular python objects. You can update existing attributes, change an attribute's data type, add new attributes, or remove them entirely using standard Python syntax.

The following example demonstrates these operations on a Person object named bob:

# Assume 'bob' is an existing memo object: Person("Bob", 30)
 
# 1. Update an attribute's value
bob.name = "Robert"
 
# 2. Change an attribute's type (e.g., from int to Decimal)
bob.age = Decimal("31.0")
 
# 3. Add a new attribute
bob.address = "456 Elm St"
 
# 4. Remove an attribute
del bob.address

Flexible Schemas

By design, memo classes are schema-less. This means you can create instances of the same class that have different data types for the same attribute.

While flexible schema is great, you can still enforce strict data validation, by integrating third-party libraries like pydantic or marshmallow.

Even though no schema is enforced, the library tracks the data types you use. You can inspect this information with the dbzero.get_schema function (see . For each attribute, it returns:

  • primary_type: The most common data type used.
  • all_types: A list of all data types ever used for that attribute across all instances.

The example below shows three Car objects where the photo attribute is stored as None, a URL string, and bytes.

@db0.memo
class Car:
    def __init__(self, brand, model, year, photo):
        self.brand = brand
        self.model = model
        self.year = year
        self.photo = photo
 
# Create instances with different types for the 'photo' attribute
toyota = Car("Toyota", "Corolla", 2020, None)
bmw = Car("BMW", "X5", 2021, "[https://example.com/bmw-x5.jpg](https://example.com/bmw-x5.jpg)")
audi = Car("Audi", "A4", 2022, open("car_photo.jpg", "rb").read())
 
# Inspect the schema inferred from the objects
db0.get_schema(Car)
 
# Output:
# {
#  'brand': {'primary_type': <class 'str'>, 'all_types': [<class 'str'>]},
#  'model': {'primary_type': <class 'str'>, 'all_types': [<class 'str'>]},
#  'year': {'primary_type': <class 'int'>, 'all_types': [<class 'int'>]},
#  'photo': {
#    'primary_type': <class 'bytes'>,
#    'all_types': [<class 'bytes'>, <class 'str'>, <class 'NoneType'>]
#  }
# }

Class Inheritance

Memo classes support inheritance, allowing you to create a hierarchy of related classes, just as you would with regular Python classes. A memo class can inherit from other memo classes or from standard Python classes. When you create an instance of a child class, it functions as a single memo object that inherits attributes and methods from its parents. The maximum inheritance depth is 255 levels, which is sufficient for virtually any application.

This example shows an ElectricCar that inherits from Car, which in turn inherits from Vehicle.

@db0.memo
class Vehicle:
    def __init__(self, brand: str, model: str, year: int):
        self.brand = brand
        self.model = model
        self.year = year
 
@db0.memo
class Car(Vehicle):
    def __init__(self, brand: str, model: str, year: int, fuel_type: str):
        super().__init__(brand, model, year)
        self.fuel_type = fuel_type
 
@db0.memo
class ElectricCar(Car):
    def __init__(self, brand: str, model: str, year: int, battery_capacity: float):
        # The fuel_type is fixed to "electric" for this class
        super().__init__(brand, model, year, "electric")
        self.battery_capacity = battery_capacity
 
# Create an instance of the child class
nissan_leaf = ElectricCar("Nissan", "Leaf", 2024, 40.0)
 
# The instance is recognized as a member of its entire inheritance chain
print(f"Is it a Vehicle? {isinstance(nissan_leaf, Vehicle)}")
# Output: Is it a Vehicle? True
 
print(f"Is it a Car? {isinstance(nissan_leaf, Car)}")
# Output: Is it a Car? True
 
print(f"Is it an ElectricCar? {isinstance(nissan_leaf, ElectricCar)}")
# Output: Is it an ElectricCar? True

Multiple Inheritance and Mixins

Directly inheriting from more than one memo class is not supported. However, you can achieve similar functionality by using mixins. A mixin is a standard Python class that provides a specific set of methods or attributes. A memo class can inherit from a single parent memo class and one or more standard Python mixin classes. This pattern allows you to compose functionality from different sources without violating the single-memo-parent rule.

In this example, ElectricCar inherits its core behavior from Car (a memo class) and adds battery-related features from the HasBattery mixin (a regular Python class).

# Assume 'Car' is an existing @memo class that requires brand, model, year, and fuel_type.
 
# A standard Python class designed to be used as a mixin.
class HasBattery:
    def __init__(self, battery_capacity: float):
        self.battery_capacity = battery_capacity
 
    def get_range(self) -> float:
        # A method provided by the mixin (e.g., 5 km of range per kWh)
        return self.battery_capacity * 5.0
 
@db0.memo
class ElectricCar(Car, HasBattery):
    def __init__(self, brand: str, model: str, year: int, battery_capacity: float):
        # Call the __init__ for the parent @memo class
        Car.__init__(self, brand, model, year, fuel_type="electric")
        # Call the __init__ for the mixin class
        HasBattery.__init__(self, battery_capacity)
 
# Create an instance
nissan_leaf = ElectricCar("Nissan", "Leaf", 2024, 40.0)
 
# Call a method from the mixin
print(f"Range: {nissan_leaf.get_range()} km")
# Output: Range: 200.0 km

Object Lifetime and Persistence

The lifetime of a memo object is managed automatically through reference counting. An object (excluding singletons) is guaranteed to exist in the storage as long as there is at least one active reference to it. There is no need for explicit "save" or "delete" of memo objects.

An active reference can be:

  • An attribute of another memo object.
  • A collection holding the object.
  • A tag pointing to the object.
  • A Python variable in your application's current scope.
💡

Objects are kept alive also by tags you assign to them, so they can be retrieved with a query at any time (see Working with Tags). To truly "delete" a tagged objects, you must first remove all tags from it.

When the last reference to an object is removed, it is automatically garbage-collected and deleted from storage.

Keep in mind that references from Python variables are only valid while the dbzero workspace is open. When dbzero is closed, these variables become stale, and any attempt to access the memo object through them will result in an error.

Memo Singletons

A singleton is a special memo class restricted to a single instance. You can think of it as a "root" object or a central entry point for your application's data. They are useful for managing global state or configuration.

Unlike regular memo objects, singletons are not reference-counted. They persist permanently until they are explicitly deleted.

You create a singleton by decorating its class with @dbzero.memo(singleton=True).

  • To create it, call the constructor with its arguments, usually once at application startup.
  • To retrieve it later, call the constructor without any arguments. To do this more explicitely, you can also use dbzero.fetch(TypeName) (see fetch).

This example defines a singleton MySalesApp to hold application-wide data.

# Assume 'Client' is another @memo class
@db0.memo(singleton=True)
class MySalesApp:
    def __init__(self, market_name: str):
        self.market_name = market_name
        self.customers = [] # Use a list for .append()
        self.prices = {}
        self.special_offers = {}
 
    def add_customer(self, customer: Client):
        self.customers.append(customer)
 
# --- Initialization phase ---
# The singleton is created here with its initial arguments.
root = MySalesApp("UK")
root.add_customer(Client(email="george.montana@gmail.com", account_id="12345"))
 
# --- Later, in another part of the code ---
def calculate_price(product, quantity):
    # Retrieve the existing singleton instance by calling with no arguments.
    app = MySalesApp()
    price_per_unit = app.prices.get(product, 0)
    return price_per_unit * quantity

Object UUIDs and Retrieval

Every memo object has a permanent universally unique identifier (UUID) upon creation.

These UUIDs have several key properties:

  • Unique & Permanent: They are guaranteed to be unique across all systems and are never reused, even after an object is deleted.
  • Compact & Safe: They are short, URL-friendly strings. They are also "intelligence-free," meaning they don't reveal any internal information about the data they identify.

Retrieving an object by its UUID is the most efficient way to access data, offering a time complexity of O(1).

You can get an object's UUID with dbzero.uuid and retrieve the object using dbzero.fetch.

obj = Person("Alice", 30)
 
# Get the UUID of the object
uuid = db0.uuid(obj)
print(f"Object UUID: {uuid}")
 
# Fetch the object by its UUID (the fastest retrieval method)
retrieved_obj = db0.fetch(uuid)
print(f"Retrieved name: {retrieved_obj.name}")
 
# You can also provide a class to ensure the retrieved object is of the expected type.
# This will raise an error if the UUID exists but points to a different type of object.
retrieved_person = db0.fetch(uuid, Person)
print(f"Retrieved person's name: {retrieved_person.name}")

Stable Type Identification with id

By default, the library links a Python class to its stored data using a combination of its class name, module name, and constructor fields. This is convenient and works well for minor changes, like adding a new attribute. However, this link can break if you perform major refactoring, such as renaming the class while moving it to a different file. To create a permanent identifier that survives these changes, you can assign an explicit type id.

A type id ensures that your class remains linked to its data, regardless of any future modifications to its name, location, or structure. A recommended naming convention for the type id is a globally unique, namespaced string to prevent collisions, such as /organization/project/module/ClassName.

# This type `id` creates a permanent link to the class's data.
@db0.memo(id="/acme/destruction-planner/utilities/Calendar")
class AppointmentBook:
    # The class name (originally 'Calendar') can be refactored freely,
    # as can its fields and module location, because the type `id` remains constant.
    pass

Converting to Native Python Types

To convert a memo object and its entire graph of nested objects into native Python types (like a dict or list), use the dbzero.load function. This is essential for tasks like serializing data for an API response or exporting it to a standard format like JSON. dbzero.load() recursively walks through the object and all its attributes. To prevent infinite recursion, it will raise an error if it detects a cycle in the object graph.

You can customize the conversion logic for any class by implementing a __load__(self, ...) method. This gives you full control over the output, allowing you to hide fields, add computed values, or create different representations based on arguments passed to dbzero.load().

The following example shows both default and custom conversion:

# Default conversion recursively creates dictionaries.
person_dict = db0.load(Person("Alice", 30))
# person_dict is {'name': 'Alice', 'age': 30}
 
# --- Custom Conversion Logic ---
@db0.memo
class Car:
    def __init__(self, brand, model, year):
        self.brand = brand
        self.model = model
        self.year = year
 
    def __load__(self, detailed=False):
        # This method controls how Car objects are converted.
        if detailed:
            # Return a detailed dictionary
            return {
                "brand": self.brand,
                "model": self.model,
                "year": self.year,
            }
        # By default, return a simple summary string
        return f"{self.brand} {self.model}"
 
# --- Using the custom __load__ method ---
my_car = Car("Toyota", "Corolla", 2020)
 
# 1. Default case (detailed=False)
summary_view = db0.load(my_car)
# summary_view is now "Toyota Corolla"
 
# 2. Detailed case
detailed_view = db0.load(my_car, detailed=True)
# detailed_view is {'brand': 'Toyota', 'model': 'Corolla', 'year': 2020}