On a Django ticketing site, why is a custom path converter whose to_python() loads the Event row from the database a risky design?
answer
- when resolution happens
- which exception means no match
- the converter never sees the request
- resolution can run more than once
- event loop and sync ORM
basics
~20 sto_python() runs during URL resolution, outside the view: a missing row raises DoesNotExist, which is not ValueError and becomes a 500; it cannot see the request to filter by user; and under ASGI a sync query there raises SynchronousOnlyOperation.
solid answer
~40 sConverters run while Django resolves the URL, before `process_view` hooks and the view, and they receive only the matched string. `Event.objects.get(slug=value)` in `to_python()` raises `Event.DoesNotExist` for an unknown slug, which the resolver does not treat as no match, so the request becomes a 500. Converting it to `ValueError` gives a 404 but also lets later patterns, such as a catch-all, answer instead. The converter cannot scope the query to the current user or to published events, it runs again on every extra resolve (CommonMiddleware's slash check, `resolve()` in code and tests), and under ASGI resolution runs in the event loop, where a sync ORM call raises `SynchronousOnlyOperation`. Its single shared instance must not cache rows. Keep the converter to text parsing and let the view fetch the object with `get_object_or_404()`.
code
python · 16 linesimport enum
class Venue(enum.Enum):
MAIN_HALL = 'main-hall'
ROOFTOP = 'rooftop'
class VenueConverter:
regex = '[a-z-]+'
def to_python(self, value):
return Venue(value) # ValueError for an unknown code -> no match
def to_url(self, value):
return Venue(value).valuego deeper
Recall that path converters should only turn URL text into simple values, and that loading the object belongs in the view.
Explain that only ValueError means no match, so DoesNotExist from a converter becomes a 500, and that converters receive the matched text without the request.
Show the production failure modes: 500s on bad slugs, unscoped lookups bypassing authorization, repeated hidden queries, and SynchronousOnlyOperation after moving to ASGI.
Set a codebase rule that converters stay pure and I/O-free, and give teams a shared, access-aware fetch helper instead of URL-level lookups.
## The tempting design On an event-ticketing site many views start by loading an `Event` from its slug, so someone writes a converter that does it once for everyone: ```python from .models import Event class EventConverter: regex = '[-a-zA-Z0-9_]+' def to_python(self, value): return Event.objects.get(slug=value) # the risky part def to_url(self, event): return event.slug ``` With `register_converter(EventConverter, 'event')`, a route `events/<event:event>/` hands views an `Event` instance. It looks tidy, but it moves a database query into URL resolution, a phase with different rules from the view. ## When and how to_python() runs Django resolves the URL inside its request handler, after the request phase of the middleware and before any `process_view` hook or the view itself. For each pattern whose regex fits the path, the pattern's converters' `to_python()` methods are called with **only the matched text**: no request, no user, no tenant. The one exception the resolver interprets is `ValueError`, which means no match. ## What goes wrong 1. **A missing event becomes a 500.** `Event.DoesNotExist` is not `ValueError`, so it escapes resolution. Django's exception handling maps `Http404`, `PermissionDenied` and a few request errors to 4xx responses, but not `DoesNotExist`, so a mistyped slug produces a server error and an error log entry. 2. **Catching it changes routing, not just the status.** Re-raising as `ValueError` makes the pattern decline, so resolution continues. A later catch-all, such as a CMS page route, may now answer instead of a clean 404. 3. **No request, no authorization.** The converter cannot filter out unpublished events, private events or another tenant's rows, because it never sees `request.user`. Every view using `<event:...>` inherits an unscoped lookup and must remember to re-check access. 4. **Hidden, repeated queries.** A query runs on every resolution, including the extra ones: `CommonMiddleware` resolves a slashless 404 path again to decide on a redirect, and code or tests call `resolve()`. None of it is visible in the view, and query tuning such as `select_related()` cannot vary per view. 5. **ASGI breaks it.** Under ASGI, the handler resolves the URL inside the event loop. A synchronous ORM call there raises `SynchronousOnlyOperation`, so a converter that worked under WSGI fails when the project moves to async serving. 6. **Shared instance.** `register_converter()` stores one instance used by every request and thread; caching rows on it to save queries would leak data between requests. 7. **Reversing gets brittle.** `to_url()` now expects an `Event`; a caller passing the slug string gets an `AttributeError` from `reverse()` instead of a URL. ## The better split | Concern | Converter | View | |---|---|---| | Is the text well formed? | yes: regex and cheap parsing | - | | Does the row exist? | no | `get_object_or_404()` returns a 404 | | May this user see it? | no request available | filter with `request.user` | | Query tuning | fixed for all routes | `select_related()` per view | | Async views | would need a sync query | uses async ORM methods | ```python from django.shortcuts import get_object_or_404, render from .models import Event # path('events/<slug:slug>/', event_detail, name='event-detail') def event_detail(request, slug): event = get_object_or_404(Event.objects.filter(is_published=True), slug=slug) return render(request, 'events/detail.html', {'event': event}) ``` ## When a converter lookup might still be defended - A small, static, in-memory mapping, such as a fixed set of venue codes to enum members, where no I/O happens and a miss raises `ValueError` on purpose. - Even then, keep it pure: no database, no cache server, no network call. ## Spotting it in an existing codebase - A `converters.py` module that imports models or calls a manager method is the first thing to look for. - Error reports whose tracebacks pass through URL resolution and end in a `DoesNotExist` point at a converter doing lookups. - Views whose signatures take model instances directly, instead of slugs or keys, usually hide a lookup in a converter. - Query logs showing a query before any view code runs, or the same query twice for one 404, confirm it. The rule of thumb for interviews: converters parse, views fetch.
- Why does Django map Http404 raised in a view to a 404 but DoesNotExist to a 500?Django's exception handling converts a fixed set of exceptions, `Http404`, `PermissionDenied`, `BadRequest` and suspicious-operation errors, into client-error responses. `Model.DoesNotExist` is an ordinary exception, so it is treated as a server error. `get_object_or_404()` exists to translate a missing row into `Http404`.
- How would you migrate views that already depend on an EventConverter?Change the routes to `<slug:slug>`, keep a helper that loads a published, user-visible `Event` or raises `Http404`, and call it at the top of each view. Update `reverse()` callers to pass `event.slug`. Tests that request unknown and unpublished slugs should now expect 404s rather than 500s.
saying these in an interview costs you the question
- A DoesNotExist raised in to_python() is turned into a 404 automatically.
- Converters see request.user, so they can enforce permissions.
- Each URL is resolved exactly once per request, so a converter query runs once.
- A converter's sync ORM call is safe under ASGI because converters run in a thread pool.
- Caching looked-up rows on the converter instance is harmless because each request gets its own converter.