info@altius-group.ch
Froideville, Waadt
DE

Most account deletions are a mistake

/ 13 Min. Lesezeit / aktualisiert 04.09.2026

The delete-my-account feature is usually one button and one user.delete(). It is correct, it is what the law requires, and it is wrong in a way that only shows up in support.

The change came from the client, not from us:

"""
Le site supprimait sur-le-champ, sur un seul bouton. Le client l'a jugé « trop
facile », et il a proposé mieux que ce que nous proposions : **geler le compte,
et supprimer les données au bout d'un certain temps**. La raison n'est pas
technique. La plupart des suppressions sont des gestes de colère ou d'erreur, et
un délai les rattrape : sans lui, un artiste qui ferme son compte un soir de
dépit a perdu quinze œuvres et un dossier de communication que personne ne peut
reconstituer.
"""

The site deleted immediately, on one button. The client found it “too easy” and proposed better than what we were proposing: freeze the account, delete the data after a delay. The reason is not technical. Most deletions are gestures of anger or error, and a delay catches them.

This is the account closure in the platform of the Rencontres de Bamako, the African biennial of photography — where the account being deleted belongs to an artist, and the data being erased is fifteen works and a submitted application that took weeks to assemble.

Almost every decision in the module is about what the person actually meant, and most of them are three lines long.

Two states, and a word you may not reuse

"""
VOCABULAIRE. On dit « clôture », jamais « gel ». Le mot *gelé* est déjà pris ici
et veut dire autre chose — `Artist.espace_gele`, l'espace fermé en écriture
après l'échéance d'une édition, alors que la personne existe toujours et relit
son dossier. Deux sens pour un mot, et plus personne ne relit ces écrans.
"""

We say “closure”, never “freeze”. The word frozen is already taken here and means something else — the workspace closed for writing after an edition’s deadline, while the person still exists and is rereading their file. Two meanings for one word, and nobody reads these screens any more.

A vocabulary note, at the top of the module, before any code.

Both states are “the account is not fully usable”, and they are completely different: espace_gele is a deadline passing — the artist is still there, still logged in, reading their own submission. Closure is the account being on its way out of existence.

If both are called “frozen”, then every conditional, every template, every support conversation and every future developer has to disambiguate from context. The cost is not confusion in the code — a developer reads the field name. The cost is in the screens: two different messages that both say frozen, and a support agent who can no longer tell a customer what state they are in.

Naming discipline is usually presented as an aesthetic. Here it is a decision about whether an interface remains readable, and it is worth writing at the top of a module in capitals so that the next person does not helpfully unify the two.

Show what disappears, counted

def ce_qui_disparait(user):
    """Ce que la clôture emportera, compté et nommé.

    Compté, et non décrit en général : « vos données seront supprimées » ne dit
    rien à personne. « 3 projets, 27 œuvres » se lit, et fait parfois reculer —
    c'est bien le but.
    """
    lignes = []
    candidatures = user.applications.count()
    if candidatures:
        lignes.append({"quoi": _("candidature(s)"), "combien": candidatures})
    artiste = getattr(user, "artist_profile", None)
    if artiste is not None:
        for libelle, combien in ((_("projet(s)"), artiste.projects.count()),
                                 (_("œuvre(s) dans votre bibliothèque"),
                                  artiste.works.count())):
            if combien:
                lignes.append({"quoi": libelle, "combien": combien})
    return lignes

Counted, not described in general: “your data will be deleted” says nothing to anybody. “3 projects, 27 works” gets read, and sometimes makes people step back — which is the point.

“Your data will be permanently deleted” is a sentence people have read a thousand times and have learned to scroll past. 27 is a number, and a number about their own work. Somebody who has forgotten they uploaded twenty-seven images finds out at the moment it matters.

Two implementation details make it honest:

Empty categories are omitted. if combien: — a person with no applications does not read “0 applications”. Zeros pad the list and make the real numbers harder to see, which is precisely backwards on a screen whose job is to make the real numbers land.

getattr(user, "artist_profile", None) — not every account is an artist. There are jurors, curators, staff. A confirmation screen that crashes on a RelatedObjectDoesNotExist for a non-artist is a screen nobody can use to leave, which is its own kind of violation.

This is worth generalising: a destructive confirmation should be an inventory, not a warning. The warning tells the person you have covered yourself. The inventory tells them what they are about to lose.

Two things typed by hand, and both are translated

#: Le mot à retaper. Traduit : demander « SUPPRIMER » à quelqu'un qui lit le
#: site en anglais, c'est lui demander de recopier une chaîne qu'il ne comprend
#: pas — ce n'est plus une confirmation, c'est un péage.
def mot_de_confirmation():
    return _("SUPPRIMER")

Asking someone reading the site in English to retype “SUPPRIMER” is asking them to copy a string they do not understand — that is no longer a confirmation, it is a toll.

The GitHub-style “type the name of the repository to confirm” pattern, and one detail almost everyone gets wrong: the word must be in the reader’s language.

The mechanism only works if the person understands what they are typing. A French word presented to an English reader degrades into a copy-paste exercise — it still costs them ten seconds, and it no longer confirms anything. It has become friction without meaning, which is the worst possible outcome: the cost is paid and the benefit is not.

The second entry is the person’s own identity, and it carries the detail I like most in this codebase:

def _sans_accent(texte):
    """« Traoré » et « traore » sont la même personne.

    Une confirmation qui exige les accents n'est plus une confirmation : c'est
    un piège tendu à quelqu'un qui tape sur un clavier qui ne les a pas.
    """
    decompose = unicodedata.normalize("NFD", (texte or "").strip().casefold())
    return "".join(c for c in decompose if not unicodedata.combining(c))

A confirmation that requires accents is no longer a confirmation: it is a trap set for someone typing on a keyboard that does not have them.

NFD decomposes é into e plus a combining acute accent; dropping every combining character leaves the base letters. Combined with casefold() — which is the correct one for comparison, not lower(), because it handles cases lower() does not.

The reasoning is about who is using the platform. A Malian photographer on a borrowed phone with an English keyboard layout cannot type Traoré. Refusing traore does not verify anything about their intent; it verifies their keyboard.

This is what localisation looks like when it goes past translating strings. The _() calls are the easy half. The hard half is noticing that a string comparison encodes an assumption about hardware.

And the expected value has a fallback:

def identite_attendue(user):
    """Son nom complet — et son adresse e-mail à défaut. Beaucoup de comptes
    n'ont pas de nom : ils se créent avec une adresse et rien d'autre. Leur
    demander « retapez votre nom » alors qu'aucun nom n'est enregistré ferait
    d'une confirmation une impasse.
    """
    return user.full_name or user.email

Ask for a name that was never collected and the confirmation cannot be satisfied by anyone — the account becomes undeletable, and the only route out is a support request. A confirmation that some users cannot pass is not a strong confirmation; it is a bug that looks like security.

Everything goes at once, except the erasure

def clore(user, motif="", message=""):
    """Ferme le compte SANS rien effacer. Renvoie la date d'effacement prévue.

    Tout part en même temps — la connexion et la visibilité publique. Laisser
    la fiche en ligne le temps du délai serait le pire des demi-gestes : la
    personne a demandé à disparaître, elle doit disparaître tout de suite ; ce
    qui attend, c'est l'effacement, pas le retrait.
    """
    user.cloture_demandee_le = timezone.now()
    user.cloture_motif = motif or ""
    user.cloture_message = (message or "").strip()
    user.is_active = False
    user.save(update_fields=[...])
    _retirer_du_site_public(user, publier=False)
    return efface_le(user)

The person asked to disappear, they must disappear immediately; what waits is the erasure, not the withdrawal.

This is the line that makes a grace period ethical rather than a dark pattern.

The lazy version keeps everything live during the delay — the account still works, the profile is still on the site — and only stops at erasure. It is easier to build and easier to undo, and it means someone who asked to leave is still publicly listed for thirty days on an international biennial’s website. They asked to be gone. They are not gone. The delay was supposed to protect them and it has become a delay imposed on them.

So the split is precise: visibility and access are immediate; only destruction is deferred. From the outside, and from the person’s own point of view, the account is gone the same day. What survives is a row that nobody can see and a link in an email.

The reversal is symmetric:

def rouvrir(user):
    """Annule la clôture et rend tout. Le geste de rétractation."""
    user.cloture_demandee_le = None
    ...
    _retirer_du_site_public(user, publier=True)

One link in one email, one click. Not a support ticket, not a form. If the reason for the delay is that most deletions are mistakes, then the recovery has to be cheaper than the mistake was.

Remembering what was published — and only that

Here is the subtle bug that this design avoids, and it is the sort that would otherwise be discovered publicly.

def _retirer_du_site_public(user, publier):
    """Dépublie — ou republie — la fiche d'artiste et ses projets.

    Ce qui était EN LIGNE est noté sur le compte, dans `cloture_republier`. La
    mémoire appartient à la clôture, pas aux œuvres : deux colonnes de plus sur
    `Artist` et `Project` auraient traîné là bien après que la question ne se
    pose plus.

    Sans cette mémoire, la rétractation republierait des BROUILLONS que
    personne n'a relus, sur le site d'une biennale internationale.
    """

Without this memory, the retraction would republish drafts that nobody has reviewed, on the website of an international biennial.

An artist has five projects: three published, two drafts still being written. Closure unpublishes all five. Then they change their mind.

The naive reversal sets is_published = True on everything the account owns — and puts two unreviewed drafts on the public site of a curated exhibition. Nobody notices, because the artist was told everything was restored, and the curatorial team is not watching the pages of an account that just came back.

So the closure records what was actually online:

user.cloture_republier = {
    "artistes": [artiste.pk] if artiste.is_published else [],
    "projets": [p.pk for p in projets if p.is_published],
}

and the reopening restores exactly that set, then clears the memory.

The placement is argued explicitly, and the argument is good: the memory lives on the closure (a JSON field on the user), not as a was_published column on Artist and Project. Two extra columns would be permanent structure serving a temporary question — present on every row forever, meaningful for a few days in the life of a few accounts, and a puzzle for the developer who finds them in three years.

The general rule: state that exists only during a process belongs to the process, not to the objects the process touches. When the process ends, the state disappears with it — user.cloture_republier = {} — instead of becoming schema.

What survives erasure

def effacer(user):
    """L'effacement définitif. Ne demande rien, ne vérifie pas le délai.

    Il ne reste ensuite qu'une LIGNE ANONYME — date, motif, rôle. On apprend
    pourquoi les gens partent sans garder trace des partants : c'est le seul
    usage légitime au regard du RGPD, et il ne suppose aucune donnée
    personnelle.
    """
    _transmettre_les_projets_collectifs(user)
    SuppressionCompte.objects.create(
        motif=user.cloture_motif or "",
        role=user.role,
        clos_le=user.cloture_demandee_le,
    )
    user.applications.all().delete()   # candidatures + pièces (CASCADE)
    user.delete()

You learn why people leave without keeping a trace of who left.

The tension is real and common: the organisation genuinely needs to know why artists close their accounts, and the person has exercised their right to erasure.

The resolution is that SuppressionCompte holds a reason, a role and a date — no foreign key to the user, no identifier, nothing joinable. It is a statistic, not a record. “Eleven artists left in March, seven citing the submission process” is answerable; “who was the eleventh” is not, by construction.

That is the shape to aim for. Not “we anonymise the row” — a nulled-out user row with its dates and its relations intact is often re-identifiable — but a different table, created at deletion time, containing only what the aggregate needs.

And the first line is the one that saves other people’s work:

# Un projet COLLECTIF ne meurt pas avec son porteur : il passe au premier
# associé. Sans cette reprise, l'effacement emporterait en cascade le
# travail de gens qui n'ont rien demandé.
_transmettre_les_projets_collectifs(user)

A collective project does not die with its bearer: it passes to the first associate. Without this handover, the erasure would cascade away the work of people who asked for nothing.

A collective project has several artists and one owner in the schema. CASCADE on the owner deletes it — and with it, the contributions of three other people who have not asked to leave and are not consulted.

This is where the right to erasure has a real boundary. One person’s request cannot destroy another person’s data. Ownership transfers before deletion, and the project survives.

The lesson worth extracting is uncomfortable and general: before writing a deletion, list every object that will cascade and ask, for each one, whether it belongs to more than one person. In Django this means reading every on_delete=CASCADE pointing at your user model, and reading it as a sentence about people rather than rows. The default is CASCADE and the default is a policy nobody chose.

Note also effacer does not re-check the delay. It is called by the scheduled job, which is what a_effacer() is for:

def a_effacer(maintenant=None):
    """Les comptes dont le délai de rétractation est écoulé."""
    limite = maintenant - timedelta(days=delai_en_jours())
    return get_user_model().objects.filter(
        cloture_demandee_le__isnull=False, cloture_demandee_le__lte=limite)

Selection and execution are separate: one function decides who, another does what. It makes the selection testable without deleting anything, and it makes the erasure callable by hand for a person who asks to skip the wait — which is a request you will get, and which the law is on the side of.

Finally, the delay itself is a setting, not a constant:

def delai_en_jours():
    """Le délai de rétractation, réglé au tableau de bord."""
    return SiteSettings.load().cloture_delai_jours

The right number is a policy question — the organisation’s lawyers and the organisation’s experience of its own users — and it belongs to the person who owns the policy, on a screen, not in a constant that requires a deployment.

What to take away

  1. Immediate deletion is the wrong default when the data is irreplaceable. Close now, erase later, and recover with one click.
  2. Defer only the destruction. Access and public visibility must end the same day, or the grace period is imposed on the person rather than offered to them.
  3. Count what will be lost. An inventory changes minds; a warning does not.
  4. Translate the confirmation word, ignore accents and case, and fall back to the email. A confirmation nobody can pass is a bug wearing a security badge.
  5. Record what was public before you unpublish, and put that memory on the process, not on the objects.
  6. Keep the statistic, destroy the record. A separate table with no join key, not an anonymised row.
  7. Read every CASCADE from your user model as a sentence about people. One person’s erasure must not take another person’s work.
  8. The delay is policy. Put it on a screen.
Bereit loszulegen?

Sprechen wir über Ihr Projekt

Erzählen Sie uns von Ihrem Bedarf in IoT, GIS oder individueller Entwicklung — wir melden uns innerhalb von 24 Stunden.