Skip to content

fk_redundant — columns duplicating a value reachable via a ForeignKey

ID: fk_redundant Since: 0.10.0 Order: 30 (after models, before views) Needs DB: no Severity: WARNING

What it catches

The pattern that leaks into long-lived Django schemas as they grow:

class Client(models.Model):
    raison_sociale = models.CharField(max_length=100)
    regime_fiscal  = models.ForeignKey(RegimeFiscal, on_delete=models.PROTECT)

class Mandat(models.Model):
    client         = models.ForeignKey(Client, on_delete=models.CASCADE)
    regime_fiscal  = models.ForeignKey(RegimeFiscal, on_delete=models.PROTECT)
    #              ^^^ duplicate of mandat.client.regime_fiscal

Three problems come with this :

  1. Form ergonomics. The user is forced to re-enter a value the system already knows. On a real project we saw regime_fiscal asked on Client and Mandat and Prestation and Facture — four times for the same fact.
  2. Desync risk. If Client.regime_fiscal changes, Mandat.regime_fiscal still points at the old row until someone manually updates each child. No Django-level constraint prevents this.
  3. Denormalisation without benefit. Reading via FK is one select_related join — effectively free. There's rarely a performance reason to duplicate.

The check walks every first-party model, finds its ForeignKey fields, and for each FK inspects whether the same column name exists on the FK target with the same shape. Matches produce one WARNING per duplicate.

Example finding

fk_redundant — 2 finding(s)
┌──────┬──────────────────────────────┬──────────────────────────────┬────────────────────────────────────────────────┐
│ Sev  │ Rule                         │ Location                     │ Message                                        │
├──────┼──────────────────────────────┼──────────────────────────────┼────────────────────────────────────────────────┤
│ WARN │ fk_redundant.duplicate_via_fk│ core.models.Mandat           │ core.Mandat.regime_fiscal duplicates           │
│      │                              │ .regime_fiscal               │ core.Mandat.client.regime_fiscal (via FK to    │
│      │                              │                              │ core.Client). Forms force the user to re-enter │
│      │                              │                              │ a value knowable from the FK, and the two can  │
│      │                              │                              │ desynchronize.                                 │
├──────┼──────────────────────────────┼──────────────────────────────┼────────────────────────────────────────────────┤
│ WARN │ fk_redundant.duplicate_via_fk│ facturation.models.Prestation│ facturation.Prestation.regime_fiscal           │
│      │                              │ .regime_fiscal               │ duplicates Prestation.mandat.regime_fiscal …   │
└──────┴──────────────────────────────┴──────────────────────────────┴────────────────────────────────────────────────┘

Legitimate exceptions

Not every duplicate is a bug. Three patterns the check intentionally avoids flagging or lets you silence :

1. Snapshot — frozen at a point in time

An invoice typically freezes regime_fiscal at emission : even if the client later changes regime, past invoices must keep the value used at the time they were issued. Same for devise, taux_tva, etc.

Document the intent in help_text — the check auto-detects snapshot keywords and skips the field :

class Facture(models.Model):
    client = models.ForeignKey(Client, on_delete=models.PROTECT)
    regime_fiscal = models.ForeignKey(
        RegimeFiscal,
        on_delete=models.PROTECT,
        help_text="Régime fiscal au moment de l'émission (snapshot).",  # ← keyword
    )

Recognised keywords (case-insensitive substring): snapshot, au moment, fige, figé, historique, historical, frozen, at the time, denormali (for denormalised / denormalized).

2. Denormalisation for performance

Rare in practice — a select_related is virtually always fast enough. When it IS justified, document with the denormali keyword in help_text OR use the config skip_fields.

3. Legitimate override semantics

A child where the value is genuinely independent of the parent's : use the config skip_fields.

class Mandat(models.Model):
    client = models.ForeignKey(Client, on_delete=models.CASCADE)
    # regime_fiscal = models.ForeignKey(...)   ← remove

    @property
    def regime_fiscal(self):
        return self.client.regime_fiscal

Requires a data migration to drop the column. Forms and serializers stop exposing it as an input — the user fills it implicitly by picking a client.

class Mandat(models.Model):
    client = models.ForeignKey(Client, on_delete=models.CASCADE)
    regime_fiscal = models.ForeignKey(RegimeFiscal, on_delete=models.PROTECT)

    def save(self, *args, **kwargs):
        if self.client_id:
            self.regime_fiscal = self.client.regime_fiscal
        super().save(*args, **kwargs)

Keeps the column (fewer migrations) but kills the desync risk. Form can mark the field as read-only.

Configuration

[tool.django-doctor.fk_redundant]
# Apps to skip (in addition to the third-party heuristic).
skip_apps = []

# Entire models to exclude, as "app_label.ModelName".
skip_models = []

# Individual fields to exclude, as "app_label.ModelName.field_name".
# Use this for legitimate snapshot fields whose help_text doesn't (yet)
# carry a snapshot keyword.
skip_fields = [
    "facturation.Facture.devise",  # legitimate snapshot
]

# Add more names to the built-in infrastructure skip list (audit
# timestamps, PK aliases, tenant markers). Already includes : id, pk,
# uuid, created_at, updated_at, deleted_at, created_by, updated_by,
# is_active, is_deleted, active, archived, tenant_id, langue_saisie
# (+ French variants cree_par, modifie_par, etc.).
extra_infra_fields = []

# Disable the built-in skip list entirely (rarely useful).
keep_infra_fields = false

# Flag scalar-scalar duplicates too (CharField/TextField with the same
# name and type on model and FK target). Off by default because name
# collisions on `description`, `notes`, `libelle` are frequent and
# drown the signal.
detect_scalar = false

Known caveats

  • Anchor-aware (since 0.10.3). If the source FK through which the duplicate would be read is NULLABLE and the duplicated field name is itself a direct FK on the source model, the check skips the finding. The direct FK is the anchor that carries the reliable value when the nullable path is None. Classic altiusone case : Document.mandat is NOT a redundancy of Document.dossier.mandat because Document.dossier is SET_NULL — dropping the direct Document.mandat would break standalone documents. The heuristic still catches legitimate redundancies where the source FK is NOT NULL (e.g., Mandat.regime_fiscal via Mandat.client.regime_fiscal with non-null client).
  • 1-hop only. If Prestation → Mandat → Client all have regime_fiscal, the check flags Prestation.regime_fiscal against its direct FK (Prestation.mandat) and Mandat.regime_fiscal against its direct FK (Mandat.client). It does NOT walk transitively — each hop is reported independently.
  • Scalar matches off by default (since 0.10.2). Two same-type scalars sharing a name across model and FK target are NOT flagged by default — description / notes / libelle collide across unrelated models too often. Enable via detect_scalar = true if your domain warrants the signal-to-noise tradeoff. FK-to-FK duplications (same remote model on both sides) are always flagged.
  • Shape matching is strict. Two ForeignKey fields count only if they point to the same remote model. A CharField vs a ForeignKey to a choices table is NOT flagged, even though semantically it's the same duplication — use a custom project rule for that pattern.
  • The check doesn't inspect values. It only inspects schema. Two columns may share a name and type but be used for genuinely different purposes in code — use skip_fields to silence.
  • Infrastructure field names skipped by default (since 0.10.1+0.10.2). id, created_at, updated_at, created_by, is_active, etc. are always shared across models and are never domain-redundancy. See extra_infra_fields / keep_infra_fields to tune.

Scope

  • First-party models (anything outside site-packages/dist-packages).
  • Abstract and proxy models are skipped.
  • Pure schema inspection — --quick safe, no DB access, no code parsing.