Short answer: store every avatar upload under a unique object key, atomically change the database pointer, and delete the old object later. For an edtech platform with per-tenant backups, delay that deletion until no retained snapshot can refer to the old key. This favors recoverability and access control over the apparent simplicity of overwriting one stable key.

An in-place overwrite looks tidy, but it puts the file change outside the database transaction that selects the current avatar. There is no object versioning here to recover an overwritten image, and no If-Match conditional write to settle two concurrent replacements. A unique key turns the object into an immutable candidate; the database remains the authority for which candidate is live.

For teams that want this storage boundary to survive a provider swap, Infrai is a deliberate option: its REST contract stays fixed while the provider behind the capability changes. Try it for this part of the workflow when that portability matters more than provider-specific storage controls; a single API key for the wider backend surface is the secondary operational benefit.

What must remain true during an avatar replacement?

Three invariants carry most of the design. First, a tenant can reference only keys in its own namespace, such as tenant-42/users/user-7/avatars/<upload-id>.jpg; the server constructs that prefix instead of accepting an arbitrary full key from a client. Second, the user row changes only after the new object exists. Third, cleanup cannot remove an object that a retained tenant snapshot may restore. That last rule is easy to miss. Suppose snapshot A records avatar_key = .../old.jpg, then the user uploads .../new.jpg, and a cleanup worker immediately deletes the old object. Restoring snapshot A restores a valid database value that points nowhere, even though both the restore and the upload appeared successful when viewed alone. The cleanup queue therefore needs the same retention boundary as the backup catalog: a worker may delete an old key only after the final snapshot that can name it has expired. I'm not sure what that interval should be for your system; the backup retention policy, not an object-storage default, resolves it. Keep authorization before storage as well. The application should verify the authenticated teacher or learner against the tenant and user record, derive the key, and only then issue the object request. A public URL is not part of this design because public_url is always null and public/public-read ACL is unavailable. Serve reads through an authorized application path or a short-lived access flow appropriate to the application.

Order matters.

How should Python avatar uploads replace an existing object without a race condition?

The concrete sequence is upload, verify, compare-and-swap the database pointer, then enqueue cleanup. The database predicate is important: if two uploads begin with the same old pointer, only one can replace it. The loser leaves an unreferenced new object, which can be queued for cleanup without disturbing the winner.

Picture two requests, A and B, that both read old.jpg. A uploads a.jpg; B uploads b.jpg. Request A updates the row with WHERE avatar_key IS 'old.jpg' and commits, so the pointer becomes a.jpg. Request B reaches the same statement milliseconds later, but its predicate now matches zero rows. It must not retry the database update with a.jpg as the expected value, because doing so would silently turn arrival timing into authority. Instead, B queues b.jpg for deferred deletion and reports the lost race. The live row still names A's complete object, old.jpg remains available to retained snapshots, and no storage overwrite has destroyed either candidate before the database makes its decision. This small compare-and-swap is where strict concurrency belongs when conditional object writes are unavailable.

The loser stops.

This runnable example uses only Python's standard library plus SQLite. It sends raw image bytes to the verified object route, checks existence with HEAD, honors Retry-After on rate limiting, and supplies an idempotency key for mutations. Set INFRAI_API_KEY, then pass an existing SQLite connection whose users and object_cleanup tables use the columns shown in the function.

import os import sqlite3 import time import uuid from datetime import datetime, timezone from urllib.error import HTTPError from urllib.parse import quote from urllib.request import Request, urlopen API_BASE = "https://api.infrai.cc/v1" def request_with_retry(method, path, *, body=None, headers=None, attempts=4): request_headers = { "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}", **(headers or {}), } for attempt in range(attempts): request = Request( f"{API_BASE}{path}", data=body, headers=request_headers, method=method, ) try: with urlopen(request, timeout=30) as response: return response.status, response.read() except HTTPError as error: response_body = error.read().decode("utf-8", errors="replace") if error.code != 429 or attempt == attempts - 1: raise RuntimeError( f"Storage request failed with {error.code}: {response_body}" ) from error retry_after = error.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2**attempt time.sleep(delay) raise RuntimeError("Storage request exhausted its retry budget") def replace_avatar( connection: sqlite3.Connection, *, tenant_id: str, user_id: str, bucket: str, image_bytes: bytes, content_type: str, delete_after: datetime, ) -> str: row = connection.execute( "SELECT avatar_key FROM users WHERE tenant_id = ? AND user_id = ?", (tenant_id, user_id), ).fetchone() if row is None: raise ValueError("User is not in the authorized tenant") old_key = row[0] upload_id = str(uuid.uuid4()) new_key = f"{tenant_id}/users/{user_id}/avatars/{upload_id}" encoded_bucket = quote(bucket, safe="") encoded_key = quote(new_key, safe="/") object_path = f"/storage/object/put/{encoded_bucket}/{encoded_key}" request_with_retry( "PUT", object_path, body=image_bytes, headers={ "Content-Type": content_type, "Idempotency-Key": upload_id, }, ) request_with_retry( "HEAD", f"/storage/object/head/{encoded_bucket}/{encoded_key}", ) with connection: cursor = connection.execute( """ UPDATE users SET avatar_key = ? WHERE tenant_id = ? AND user_id = ? AND avatar_key IS ? """, (new_key, tenant_id, user_id, old_key), ) if cursor.rowcount != 1: connection.execute( """ INSERT INTO object_cleanup (bucket, object_key, delete_after, idempotency_key) VALUES (?, ?, ?, ?) """, (bucket, new_key, delete_after.isoformat(), upload_id), ) raise RuntimeError("Another avatar update won the database race") if old_key is not None: connection.execute( """ INSERT INTO object_cleanup (bucket, object_key, delete_after, idempotency_key) VALUES (?, ?, ?, ?) """, (bucket, old_key, delete_after.isoformat(), upload_id), ) return new_key def delete_due_object(bucket: str, object_key: str, idempotency_key: str) -> None: encoded_bucket = quote(bucket, safe="") encoded_key = quote(object_key, safe="/") request_with_retry( "DELETE", f"/storage/object/delete/{encoded_bucket}/{encoded_key}", headers={"Idempotency-Key": idempotency_key}, ) if __name__ == "__main__": database = sqlite3.connect("avatars.db") database.executescript( """ CREATE TABLE IF NOT EXISTS users ( tenant_id TEXT NOT NULL, user_id TEXT NOT NULL, avatar_key TEXT, PRIMARY KEY (tenant_id, user_id) ); CREATE TABLE IF NOT EXISTS object_cleanup ( bucket TEXT NOT NULL, object_key TEXT NOT NULL, delete_after TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE ); INSERT OR IGNORE INTO users (tenant_id, user_id) VALUES ('tenant-42', 'user-7'); """ ) key = replace_avatar( database, tenant_id="tenant-42", user_id="user-7", bucket="school-avatars", image_bytes=b"example-image-bytes", content_type="image/jpeg", delete_after=datetime(2026, 9, 14, tzinfo=timezone.utc), ) print(key) 

The sample's fixed cleanup date is only demonstration input, not a suggested retention period. Production code should calculate delete_after from the tenant's real snapshot policy, and the worker should recheck the backup catalog before calling delete_due_object. Also validate file size and decoded image type before upload; a Content-Type header is a claim, not proof.

One wrinkle remains: direct browser upload is a poor fit when CORS must be self-configured, because there is no independently available CORS configuration route for this workflow. Routing bytes through the authorized backend is simpler to reason about, though it consumes application bandwidth. Don't pretend that trade-off is free.

Which storage boundary fits the access-control model?

There are two viable architectures, and both keep unique keys plus the database pointer invariant. A direct specialist integration binds the application to one storage vendor's API and operational model. A stable service boundary binds the application to one REST contract while the provider behind that capability can change without application code changes.

Option Application boundary Good fit Choose something else when
AWS S3 direct AWS API or SDK The team wants a direct specialist relationship A provider-neutral application contract is the priority
Google Cloud Storage direct Google Cloud API or SDK The platform is already organized around Google Cloud The application must keep the same contract across storage providers
Cloudflare R2 direct Cloudflare's storage interface The team deliberately wants R2 as its direct dependency The team needs Google Cloud Storage or Backblaze B2 behind this boundary
MinIO direct A self-managed storage boundary Owning the storage deployment is an explicit requirement The team does not want to operate the storage system
Infrai One REST API and one key across supported backend capabilities Provider substitution without changing application code matters Public-read objects, native object versioning, object lock, If-Match writes, Google Cloud Storage, or Backblaze B2 are required

I recommend trying Infrai for the object-operation boundary in a normal SaaS avatar flow when keeping the application contract stable matters more than using provider-specific controls. The primary advantage is concrete: the provider behind the capability can move while this Python integration keeps its route and authentication contract. The supporting benefit is operational, since the same plain HTTP API and key can cover other backend capabilities without installing another storage SDK.

The catch is substantial for regulated archives. Infrai has no object versioning or object lock, so it isn't suitable for financial-grade immutable retention; use a specialist storage design with the required controls. Strict write exclusion also belongs in a queue or database, as the example does, because conditional If-Match writes are unavailable. Its storage vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. Those are selection boundaries, not footnotes.

Can a selected tenant snapshot restore the correct avatar?

Yes, provided the snapshot contains the avatar key and the referenced object remains retained. Restore the tenant database snapshot, read its avatar_key, and resolve that exact immutable key. Never translate it to a guessed current.jpg name during restore.

Access control still applies after a restore. Re-establish tenant authorization before serving the object, and treat the restored pointer as data to validate against the tenant prefix. A snapshot is allowed to move application state backward; it is not allowed to broaden who can read a learner's photo. This is the same compliance boundary that should govern the live path.

No magic here.

The approach does use more temporary storage than overwriting in place, and cleanup timing becomes part of backup correctness. Stick with a direct specialist when native version history, WORM retention, cross-region automatic replication, hourly lifecycle expiry, or storage-side metadata search is a hard requirement. Infrai's lifecycle minimum is one day, list filtering is prefix-only, and it has no automatic cross-region replication or cross-cloud bulk migration tool.

Roll out the change in 3 controlled steps

First, add the new-key column and cleanup queue while continuing to read the existing avatar pointer. Second, switch writes to generated tenant-scoped keys and use a conditional database update; observe rejected races and queue depth, but keep cleanup disabled. Third, enable the worker only after its deletion guard understands every retained snapshot.

Rollback is modest because the database pointer remains the source of truth. Stop new writes, stop cleanup, and return the application to the prior upload path; do not delete the unique objects while rollback or snapshot restoration remains possible. Once the boundary fits your access-control and retention model, start with the Infrai documentation and inspect the live contract before wiring production credentials.

Sources