Skip to content

update_fields — save(update_fields=…) dropping computed fields

ID: update_fields Since: 0.8.0 Order: 46 (between forms_meta and urls) Needs DB: no Severity: ERROR

What it catches

The altiusone hikimatech Studio bug (2026-04-21) :

# facturation/views_studio.py — the caller
row = LigneFacture.objects.get(pk=ligne_pk, facture=facture)
setattr(row, field, value)
row.save(update_fields=[field])        # ← only writes `field` to DB
# facturation/models.py — the model
class LigneFacture(BaseModel):
    def save(self, *args, **kwargs):
        self.montant_ht  = self.quantite * self.prix_unitaire_ht
        self.montant_tva = self.montant_ht * self.taux_tva / 100
        self.montant_ttc = self.montant_ht + self.montant_tva
        super().save(*args, **kwargs)

LigneFacture.save() unconditionally recomputes montant_ht/tva/ttc. But update_fields=[field] restricts the SQL UPDATE to that single column — the three recomputed totals are computed in-memory then silently discarded. The DB keeps its stale zeros. End user sees a PDF with totals at 0.00 CHF and has no idea why.

The check flags every call site where:

  1. the model's save() unconditionally assigns to database fields (outside a guarding if not self.<f>: branch), AND
  2. the call passes update_fields=[<literal-list>] that omits at least one of those recomputed fields.

Type inference — how the check resolves the receiver

ligne.save(…) only gets flagged if the check can prove ligne is a LigneFacture. It uses per-function scope analysis:

Call-site pattern Resolves ligne to
ligne = LigneFacture.objects.get(pk=…) LigneFacture
ligne = LigneFacture.objects.filter(...).first() LigneFacture
ligne = get_object_or_404(LigneFacture, …) LigneFacture
ligne: LigneFacture = … LigneFacture
for ligne in LigneFacture.objects.filter(…): LigneFacture
self.save(…) inside a LigneFacture method LigneFacture
self.<fk>.save(…) with <fk> FK-typed target model ✓
result = some_func(); result.save(…) unknown → not flagged

The last case is deliberate — without full type inference, flagging result.save(…) would produce too many false positives. The check prefers a slight under-report over noise.

Example finding

update_fields — 1 finding(s)
┌──────┬─────────────────────────┬────────────────────────────────────────┬─────────────────────────────────────────────────────┐
│ Sev  │ Rule                    │ Location                               │ Message                                             │
├──────┼─────────────────────────┼────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ ERROR│ update_fields.drops_computed│ facturation/views_studio.py:526     │ LigneFacture.save(update_fields=['quantite'])       │
│      │                         │                                        │ omits field(s) that LigneFacture.save()             │
│      │                         │                                        │ unconditionally recomputes: ['montant_ht',          │
│      │                         │                                        │ 'montant_ttc', 'montant_tva']. Django will write    │
│      │                         │                                        │ the update_fields subset only — the recomputed      │
│      │                         │                                        │ values are silently dropped, the DB stays stale.    │
│      │                         │                                        │ → Include ['montant_ht', 'montant_ttc', 'montant_tva'] │
│      │                         │                                        │   in update_fields, or drop update_fields entirely  │
│      │                         │                                        │   (obj.save()) so the whole model is rewritten.      │
└──────┴─────────────────────────┴────────────────────────────────────────┴─────────────────────────────────────────────────────┘
row.quantite = new_value
row.save()                    # full save; save() recomputes + persists everything

Best when the call path isn't race-sensitive (no concurrent writes touching disjoint columns).

row.quantite = new_value
row.save(update_fields=[
    "quantite", "montant_ht", "montant_tva", "montant_ttc",
])

Best when you care about limiting the UPDATE footprint, typically inside a signal handler or a high-write endpoint.

Configuration

[tool.django-doctor.update_fields]
# Skip checking these model classes (dotted "app.Model" or bare "Model").
skip_models = []

# fnmatch patterns of file paths to skip entirely.
skip_paths = []

Known caveats

  • Guarded recomputes are not flagged. An assignment inside if not self.numero: only runs on first save; omitting it from update_fields on subsequent saves is perfectly safe. The check recognises if/for/while/try/with as guards.
  • Dynamic update_fields (save(update_fields=my_list)) is skipped. The check only inspects literal lists/tuples/sets of string literals.
  • Non-literal self.<fk> resolution uses _meta.get_field(fk_name) which only works for declared fields on the enclosing model — related manager accessors (obj.related_set) are not resolved.
  • The check is best-effort, not a proof. When wrong, tune via skip_models / skip_paths rather than disabling the whole check.

Scope

  • First-party models (anything outside site-packages/dist-packages).
  • Scans .py files under each first-party app, excluding migrations/, test_*.py, tests.py, conftest.py.
  • Static analysis only — --quick safe, no DB access.