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
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)
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',))
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)
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")
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
contextis 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)