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 :
- Form ergonomics. The user is forced to re-enter a value the system
already knows. On a real project we saw
regime_fiscalasked on Client and Mandat and Prestation and Facture — four times for the same fact. - Desync risk. If
Client.regime_fiscalchanges,Mandat.regime_fiscalstill points at the old row until someone manually updates each child. No Django-level constraint prevents this. - Denormalisation without benefit. Reading via FK is one
select_relatedjoin — 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.
Recommended fixes¶
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.mandatis NOT a redundancy ofDocument.dossier.mandatbecauseDocument.dossieris SET_NULL — dropping the directDocument.mandatwould break standalone documents. The heuristic still catches legitimate redundancies where the source FK is NOT NULL (e.g.,Mandat.regime_fiscalviaMandat.client.regime_fiscalwith non-null client). - 1-hop only. If
Prestation → Mandat → Clientall haveregime_fiscal, the check flagsPrestation.regime_fiscalagainst its direct FK (Prestation.mandat) andMandat.regime_fiscalagainst 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/libellecollide across unrelated models too often. Enable viadetect_scalar = trueif 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
CharFieldvs aForeignKeyto achoicestable 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_fieldsto 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. Seeextra_infra_fields/keep_infra_fieldsto tune.
Scope¶
- First-party models (anything outside
site-packages/dist-packages). - Abstract and proxy models are skipped.
- Pure schema inspection —
--quicksafe, no DB access, no code parsing.