Concepts & Architecture¶
This chapter covers how
ReverseRelationAdminMixin works:
the virtual fields it injects, how the admin form
lifecycle is extended, how bindings are synchronised, and
what transaction guarantees apply. For complete admin examples, see
Single binding (Company ↔ Department) and Multiple binding (Company ↔ Projects).
What the mixin injects¶
ReverseRelationAdminMixin introduces virtual form fields that proxy the
reverse side of ForeignKey and OneToOne relationships. Those fields are declared
in reverse_relations.
If your admin declares fieldsets or fields, you must include the virtual
names there so the Django template renders them. If your admin declares neither
fieldsets nor fields, Django renders all form fields by default and the
injected virtual fields will appear automatically. This
works because the mixin’s get_fields()
appends the virtual names for you and get_form()
injects the corresponding form fields dynamically.
Note
If you override get_fields()
without calling super(), or you hard-code fields/fieldsets and
omit the virtual names, the admin template will not render the virtual
fields. The form still contains them (the mixin injects them), but the layout
derives from get_fields/fieldsets.
During get_form()
the mixin removes those virtual names from the base form
(avoiding “unknown field” errors), creates
ModelChoiceField/ModelMultipleChoiceField
instances on the fly, and wires up any labels, help texts, or widgets defined on
ReverseRelationConfig.
Request-aware querysets¶
Every virtual field resolves its queryset and initial selection for the current
request. limit_choices_to can be a callable that
receives (queryset, instance, request) and returns a scoped queryset. This
lets you present only the objects a user is allowed to bind while still showing
items already attached to the instance under edit.
Single vs. multiple selections¶
multiple determines whether the field captures a
single object or a synchronised set:
multiple=False(default) — behaves like a dropdown. The chosen object’s ForeignKey is set to the admin object, and any other rows pointing at it are unbound.multiple=True— represents the entire desired set. After form submission the mixin unbinds rows not in the selection before binding the chosen ones to the instance. The resulting database state matches the submitted list exactly.
Warning
Single-select unbinds all other objects pointing at the instance. Ensure the
reverse ForeignKey is null=True. If the relation must never be empty,
set required=True on the virtual field to prevent unbinding from raising
an IntegrityError.
Bulk operations and performance¶
ReverseRelationConfig supports an optional bulk parameter
that changes how bind/unbind operations are performed:
bulk=False(default) — uses individual model saves, triggering all Django model signals (pre_save,post_save, etc.) for each affected object.bulk=True— uses Django’s.update()method for better performance but bypasses model signals entirely.
Performance considerations:
Aspect |
Individual Saves ( |
Bulk Operations ( |
|---|---|---|
Database round-trips |
One per object |
One per operation type |
Model signals |
✅ Triggered normally |
❌ Bypassed entirely |
Performance |
Slower with large datasets |
✅ Significantly faster |
Error granularity |
✅ Per-object errors |
Batch-level errors only |
Memory usage |
Higher (object instantiation) |
✅ Lower (queryset operations) |
Best practices for bulk mode:
Enable bulk mode when managing hundreds or thousands of relationships
Ensure your application doesn’t depend on model signals for the reverse model
Use bulk mode consistently across related configurations for optimal performance
Consider the trade-off between performance and signal-based functionality
Warning
Bulk operations bypass Django’s model signal system. If your application relies
on pre_save, post_save, pre_delete, or other model signals for the
reverse relationship model, do not enable bulk mode.
Form lifecycle¶
ReverseRelationAdminMixin layers reverse-relation behaviour onto Django’s
ModelAdmin lifecycle. Configuration happens declaratively on the admin
class; the mixin then wires dynamic form fields, validation, permissions, and
persistence logic around Django’s normal flow.
Field declaration — the admin declares virtual field names in
reverse_relationsand typically lists them infieldsetsorfieldsso the Django admin template renders them. When neither layout option is declared,get_fields()appends virtual names automatically.Form construction —
get_form()strips the virtual names out of the basefieldsargument (to avoid Django’s “unknown field” errors) and delegates tosuper(). After the base form class is produced, the mixin injectsModelChoiceFieldorModelMultipleChoiceFieldinstances for each configured relation. Querysets come fromReverseRelationConfig.limit_choices_to(callable ordict) plus optionalordering.Initial data — the derived form’s
__init__resolves the queryset and current selections for the object under edit. Virtual fields point at the reverse model’s objects whose ForeignKey already references the parent instance.Render gate — if
reverse_permissions_enabledis true, the form checks permissions before rendering. By default this uses a base permission check, but can be configured to use the full permission policy to allow per-field visibility. Fields become hidden or disabled based onreverse_permission_mode. See Rendering & visibility for details.
Validation and permissions¶
ReverseRelationConfig.cleanhooks run during formclean()with(instance, selection, request). Use this for business rules such as capacity limits or forbidding unbinds.Permission evaluation happens twice:
During
clean()— when a custom policy (per-field or global) denies a specific selection, the field receives a validation error. Error messages resolve using the precedence described in Permissions.During
save()— unauthorized fields are excluded from the persistence payload to guard against crafted POSTs.
Persistence¶
ModelForm.save delegates to the base implementation and then synchronizes
reverse relations via
_apply_reverse_relations().
For each configured field:
Multi-select fields compute the exact set of rows that should point at the parent instance. Items removed from the selection are unbound (ForeignKey set to
None) before new bindings are applied.Single-select fields unbind all rows except the chosen object, then bind the target if it is not already pointing at the instance.
When reverse_relations_atomic is True (the default) all configured
fields are synchronized inside a single transaction so either all bindings are
updated or none are. Unbinds happen before binds within each field to minimise
transient uniqueness conflicts on OneToOneField or unique ForeignKeys.
Note
commit=False
If a form is saved with commit=False, the mixin defers reverse updates
until save_model(). The
payload of authorized reverse fields is stored on the form instance and
applied during the admin save hook.
- Typical use-cases
You override admin save hooks and need to inspect or adjust the parent instance before reverse bindings are persisted.
You rely on the standard Django admin lifecycle where
ModelAdmin.save_modelcoordinates the final write.
- Behavioral details
form.save(commit=False)returns the parent instance without applying reverse bind/unbind updates yet.The mixin stores only authorized reverse-field selections on the form.
save_model()applies the deferred payload exactly once.Hidden/disabled/unauthorized fields remain excluded from persistence, consistent with normal
commit=Truebehavior.
Data integrity & transactions¶
Note
By default, the mixin wraps the entire update in a single
django.db.transaction.atomic() block (reverse_relations_atomic=True).
If any virtual field raises an error, the whole operation rolls back. You can
opt out with reverse_relations_atomic=False if you prefer to persist
changes field-by-field.
Unbind before bind:
Within each field’s update, the mixin unbinds rows before binding new ones.
This avoids transient uniqueness errors on OneToOneField or ForeignKeys
with unique=True, as the old relation is cleared before the new one is
claimed.
One-to-one specifics:
Treat
OneToOneFieldrelations as single-select fields (multiple=False).If the reverse relation is non-nullable, you must configure
required=Trueor make the underlying database field nullable. Otherwise, unbinding an object would raise anIntegrityError.
Extensibility checklist¶
Provide custom widgets by supplying
ReverseRelationConfig.widgetwith a widget instance or class (e.g., DAL/Unfold).Scope querysets dynamically with a callable
limit_choices_to. Callables receive the current request and instance, allowing per-user filtering.Implement per-field
permissionpolicies or assignreverse_permission_policyon the admin for global rules. Policies may be callables or objects implementinghas_perm.Override
has_reverse_change_permission()if you need to enforce different permission codenames (add/delete) or object-level checks.
See also
Configuration — Permissions, visibility, querysets, and widgets.
Recipes — End-to-end admin setups.
Caveats — Operational edge-cases.