DEV Community

Riko
Riko

Posted on

Using Marshmallow `Method` Fields for Complex Nested Schemas in Flask

Introduction

I made an habit tracking journal app where users can write entries about their habit loops. A habit loop consists of a trigger or cue, a behavior, and a result. For privacy reasons and a clean database design, triggers are related to a user, while behaviors are available to all users. Users can write entries that have one trigger and one behavior. A user's entries are still private to them through the trigger.

I wanted an API response that returns all of a user's data with nested schemas. Due to the model relationships, there can be two hierarchical views of the same data:

  • User > Trigger > Behaviors > Entries
  • User > Behavior > Triggers > Entries

So the API response needs to:

  • List triggers with their related behaviors and only the entries that belong to both the trigger and behavior.
  • List behaviors with their related triggers and those same filtered entries.
  • Include user-specific filtering to only show relevant triggers and behaviors: a user should not see behaviors, triggers, or entries that are not their own.

Marshmallow’s Schema and Nested fields are great, but these can't easily handle multiple levels of nesting that enforce a particular context. Enter Marshmallow’s Method fields which allow you to shape complex nested data exactly how you want it.

My Solution: Marshmallow Method Fields with Context

The key was to use ma.Method fields inside my Marshmallow schemas. These let you define your own methods that compute whatever data you want when serializing.

Step 1: Create schemas for the base models — Entry, Trigger, Behavior

Each has basic auto fields for their core attributes.

class EntrySchema(ma.SQLAlchemySchema):
    class Meta:
        model = Entry
        load_instance = True
    id = ma.auto_field()
    description = ma.auto_field()
    # ... other fields
Enter fullscreen mode Exit fullscreen mode

Step 2: Build nested schemas that include filtered related entries

For example, NestedTriggerSchemaWithEntries represents a trigger but also includes entries filtered by behavior. To achieve this filtering, I pass the parent behavior object via context so the method knows which entries to include:

class NestedTriggerSchemaWithEntries(ma.SQLAlchemySchema):
    # Schema for Behavior, Nested Trigger, and Entries associated with both
    class Meta:
        model = Trigger
        load_instance = True
    id = ma.auto_field()
    name = ma.auto_field()
    description = ma.auto_field()
    user_id = ma.auto_field()

    entries = ma.Method("get_entries_for_behavior")

    def get_entries_for_behavior(self, trigger_obj):
        # Get entries that match this trigger and its parent behavior from context. 
        behavior_obj = self.context.get("behavior")
        if not behavior_obj or not trigger_obj:
            return []

        filtered_entries = [e for e in trigger_obj.entries
                            if e.behavior_id == behavior_obj.id
                            and e.trigger_id == trigger_obj.id]
        return entries_schema.dump(filtered_entries)
Enter fullscreen mode Exit fullscreen mode

This method filters entries for the trigger but only those tied to the current behavior.

Step 3: Connect it all in top-level schemas

In your BehaviorSchema, you can include a triggers field that uses the nested trigger schema above, passing the parent behavior in context:

class BehaviorSchema(ma.SQLAlchemySchema):
    class Meta:
        model = Behavior
        load_instance = True

    id = ma.auto_field()
    name = ma.auto_field()
    description = ma.auto_field()
    type = ma.auto_field()

    triggers = ma.Method("get_nested_trigger_entries")

    def get_nested_trigger_entries(self, behavior_obj):
        # Gets triggers with entries that match the parent behavior
        if behavior_obj is None or behavior_obj.triggers is None:
            return []
        triggers = []
        for trigger in behavior_obj.triggers:
            if trigger is None:
                continue
            if trigger.user_id != current_user.id:
                continue
            # Instantiate nested triggers with entries schema, passing the behavior as context
            schema = NestedTriggerSchemaWithEntries(context={"behavior": behavior_obj})
            triggers.append(schema.dump(trigger))
        return triggers

behavior_schema = BehaviorSchema()
behaviors_schema = BehaviorSchema(many=True, exclude=('triggers',))
Enter fullscreen mode Exit fullscreen mode

The same pattern applies in reverse with NestedBehaviorSchemaWithEntries inside your TriggerSchema.

class NestedBehaviorSchemaWithEntries(ma.SQLAlchemySchema):
    # Schema for Trigger, Nested Behavior, and Entries associated with both
    class Meta:
        model = Behavior
        load_instance = True

    id = ma.auto_field()
    name = ma.auto_field()
    description = ma.auto_field()
    type = ma.auto_field()

    entries = ma.Method("get_entries_for_trigger")

    def get_entries_for_trigger(self, behavior_obj):
        # Gets entries that match this behavior and its parent trigger from context. Current user should already match the trigger.
        trigger_obj = self.context.get("trigger")
        if not trigger_obj:
            return []
        if behavior_obj is None:
            return []
        filtered_entries = [e for e in behavior_obj.entries
                            if e.trigger_id == trigger_obj.id
                            and e.behavior.id == behavior_obj.id]
        return entries_schema.dump(filtered_entries)


class TriggerSchema(ma.SQLAlchemySchema):
    class Meta:
        model = Trigger
        load_instance = True

    id = ma.auto_field()
    name = ma.auto_field()
    description = ma.auto_field()
    user_id = ma.auto_field()
    behaviors = ma.Method("get_nested_behavior_entries")

    def get_nested_behavior_entries(self, trigger_obj):
        # Gets behaviors that match the user and parent trigger
        if trigger_obj is None or trigger_obj.behaviors is None:
            return []
        behaviors = []
        for behavior in trigger_obj.behaviors:
            if behavior is None:
                continue
            # Instantiate nested behaviors with entries schema, passing trigger as context
            schema = NestedBehaviorSchemaWithEntries(context={"trigger": trigger_obj})
            behaviors.append(schema.dump(behavior))
        return behaviors

trigger_schema = TriggerSchema()
triggers_schema = TriggerSchema(many=True) 
Enter fullscreen mode Exit fullscreen mode

Step 4: Nest the trigger and behavior schemas in User schema

Finally, these schemas are then used in User schema with ma.Nested. This ensures that API requests to login and get current user will return all of the user's data in one response:

class UserSchema(ma.SQLAlchemySchema):
    class Meta:
        model = User
        load_instance = True

    id = ma.auto_field()
    username = ma.auto_field()
    triggers = ma.Nested(triggers_schema)
    behaviors = ma.Nested(BehaviorSchema, many=True, attribute="behaviors")
Enter fullscreen mode Exit fullscreen mode

Benefits of this Approach

  • Fine-grained control: Instead of blindly dumping related fields, I control exactly which entries appear, filtered by multiple criteria.
  • Reusability: These nested schemas can be composed and reused wherever needed.
  • Context-aware: Passing objects through context is a powerful way to carry filtering criteria down the nested serialization chain.
  • Flask-Login aware: I can easily integrate user-based filtering right in the methods.

Conclusion

Marshmallow’s Method fields are a lifesaver when you need multi-layered nested JSON responses that must adhere to a specific user's context. They let you write custom logic to filter, transform, and enrich your data exactly how your frontend needs it.

Top comments (0)