2025, Nov 23 03:00

Django ORM m2m with an explicit through model: add() deduplicates endpoint pairs - use direct through model inserts to store multiple services

Learn why Django's many-to-many manager with a through model makes add() idempotent, skipping duplicates, and how to create extra rows via direct inserts.

Many-to-many relations with an explicit through model can surprise you when you try to attach the same pair of entities more than once with different through fields. A typical case: you link a topic and a system, but the link also stores a service in the through table. The first insert works; the second attempt with the same topic and system but a different service silently does nothing, and no error is raised.

Minimal repro of the behavior

The code below adds a system to a topic via a through model that carries an extra field. No exceptions occur, but a second call with the same topic–system pair does not create a new row in the m2m table.

from django.db import transaction
with transaction.atomic():
    topic_obj = ChatTopics.objects.get(ct_id=chat_topic_id)
    platform_obj = Systems.objects.get(sys_id=system_id)
    svc_obj = ServiceCatalog.objects.get(sc_id=service_id)
    topic_obj.ct_systems.add(
        platform_obj,
        through_defaults={"ctm_service": svc_obj}
    )

What is really happening

This is expected behavior of .add(…). The documented contract is straightforward:

Adding a second time is OK, it will not duplicate the relation

In other words, the many-to-many manager treats the relation between the endpoints (here: topic and system) as a set. If a row connecting the same topic and the same system already exists, calling .add(…) again won’t create another row, even if through_defaults contains a different service.

How to get the intended result

If you need multiple rows for the same topic–system pair that differ by the through field, don’t rely on the m2m manager’s .add(…). Create the through model entry directly. That both aligns with the behavior described above and reduces the work to a single query.

from django.db import transaction
with transaction.atomic():
    ChatTopics_m2m.objects.create(
        ctm_system_id=system_id,
        ctm_chat_topic_id=chat_topic_id,
        ctm_service_id=service_id
    )

Since this approach performs one insert, the atomic block can probably be removed.

Why this matters

Understanding how the many-to-many manager deduplicates relations prevents silent no-ops in write paths. Instead of guessing why additional rows don’t show up in the join table, you can choose the correct API: .add(…) when you want set semantics on the endpoint pair, and direct through model writes when you need multiple records that differ by extra fields.

Takeaways

When an m2m relation includes extra data via an explicit through model, .add(…) won’t create a second row for the same endpoint pair. Use a direct insert into the through model to create additional records that vary by the through fields, and consider simplifying the transaction scope because the operation is a single query.