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:
- the model's
save()unconditionally assigns to database fields (outside a guardingif not self.<f>:branch), AND - 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. │
└──────┴─────────────────────────┴────────────────────────────────────────┴─────────────────────────────────────────────────────┘
Two recommended fixes¶
Best when the call path isn't race-sensitive (no concurrent writes touching disjoint columns).
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 fromupdate_fieldson subsequent saves is perfectly safe. The check recognisesif/for/while/try/withas 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_pathsrather than disabling the whole check.
Scope¶
- First-party models (anything outside
site-packages/dist-packages). - Scans
.pyfiles under each first-party app, excludingmigrations/,test_*.py,tests.py,conftest.py. - Static analysis only —
--quicksafe, no DB access.