Views
Take a request, return a response.
- Overview
- HTTP methods map to class methods
- Template-rendering views
- RedirectView
- Async views
- ServerSentEventsView
- Lifecycle hooks
- ResponseException
- Error views
- View patterns
- FAQs
- Installation
Overview
Plain views are class-based, with a straightforward API that keeps simple views simple while giving you the full power of a class for complex cases.
from plain.http import Response
from plain.views import View
class ExampleView(View):
def get(self):
return Response("<html><body>Hello, world!</body></html>")
View handlers return a Response (or subclass like JsonResponse, RedirectResponse, etc.). To return a dict/list shorthand for JSON APIs, use APIView.
HTTP methods map to class methods
The HTTP method of the request maps directly to a class method of the same name. Define only the methods you want to support.
from plain.views import View
class ExampleView(View):
def get(self):
pass
def post(self):
pass
def put(self):
pass
def patch(self):
pass
def delete(self):
pass
If a request comes in for a method your view doesn't implement, Plain returns a 405 Method Not Allowed response automatically.
The base View class provides default options and head behavior, but you can override these too.
Template-rendering views
Views that render Jinja templates (TemplateView, FormView, DetailView, CreateView, UpdateView, DeleteView, ListView) live in the plain.templates package. Install plain.templates and import them from plain.templates.views:
from plain.templates.views import TemplateView
class ExampleView(TemplateView):
template_name = "example.html"
def get_template_context(self):
context = super().get_template_context()
context["message"] = "Hello, world!"
return context
RedirectView
RedirectView redirects to another URL.
from plain.views import RedirectView
class ExampleRedirectView(RedirectView):
url = "/new-location/"
Set status_code = 301 for permanent redirects (default is 302).
For simple redirects, configure the view directly in your URL routes.
from plain.views import RedirectView
from plain.urls import path, Router
class AppRouter(Router):
routes = [
path("/old-location/", RedirectView.as_view(url="/new-location/", status_code=301)),
]
You can also redirect to a named URL using url_name, or preserve query parameters with preserve_query_params=True.
Async views
Any view method defined with async def runs directly on the worker's event loop. This enables non-blocking I/O patterns like SSE, WebSockets, and async HTTP clients.
Important: Blocking calls in async views freeze the entire worker process — no other requests can be processed until the blocking call returns. Plain's ORM, sessions, and auth layers are all synchronous and must not be called directly from async views.
Common mistakes:
User.query.get(pk=1)— blocks the event looptime.sleep(1)— useawait asyncio.sleep(1)insteadrequests.get(...)— use an async HTTP client instead
To wrap a blocking call safely: await asyncio.get_running_loop().run_in_executor(None, blocking_fn)
Use async views only for true async I/O (SSE, async HTTP clients). For standard request/response views that use the ORM, use regular sync views — they run in the thread pool and don't block other connections.
In development (DEBUG=True), the server enables asyncio debug mode which logs warnings when a callback blocks the event loop for more than 100ms.
Type checkers will flag async def get as an LSP violation against the sync View.get base stub. Add # ty: ignore[invalid-method-override] on the async handler line — the dispatch layer handles sync and async uniformly at runtime.
ServerSentEventsView
ServerSentEventsView provides Server-Sent Events streaming. Subclass it and implement stream() as an async generator.
import asyncio
from datetime import datetime
from plain.views import ServerSentEvent, ServerSentEventsView
class ClockView(ServerSentEventsView):
async def stream(self):
while True:
yield ServerSentEvent(data={"time": datetime.now().isoformat()})
await asyncio.sleep(1)
The stream() method must yield ServerSentEvent instances. The data argument accepts strings, dicts, and lists (dicts/lists are JSON-serialized). You can also set optional event, id, and retry fields.
from plain.views import ServerSentEvent
class NotificationView(ServerSentEventsView):
async def stream(self):
yield ServerSentEvent(data="hello") # Simple string
yield ServerSentEvent(data={"count": 1}) # JSON data
yield ServerSentEvent(data="update", event="status") # Named event type
yield ServerSentEvent(data="msg", id="42", retry=5000) # With id and retry
yield ServerSentEvent.comment("keepalive") # SSE comment (keepalive)
Connect from JavaScript using the standard EventSource API:
const source = new EventSource("/events/");
source.onmessage = (event) => {
console.log(event.data);
};
// Listen for named event types
source.addEventListener("status", (event) => {
console.log("Status:", event.data);
});
Send ServerSentEvent.comment() periodically as a keepalive to prevent proxies and browsers from closing idle connections.
ServerSentEventsView only accepts GET requests. The stream() method runs on the event loop — use await for any I/O and avoid blocking calls. Use await asyncio.sleep() instead of time.sleep(), and await loop.run_in_executor() to wrap blocking operations.
Note: browsers limit HTTP/1.1 to 6 SSE connections per domain. Use HTTP/2 to avoid this limit.
Lifecycle hooks
Every view has three hooks around its handler: before_request runs before the HTTP method handler, after_response runs after the response is built (including error responses), and handle_exception converts any exception raised during dispatch into a response.
before_request
Runs before the HTTP method handler (get, post, etc.). Default is a no-op. Raise to reject the request — the exception flows through handle_exception.
from plain.http import ForbiddenError403
class MyView(View):
def before_request(self):
if self.request.user and self.request.user.is_banned:
raise ForbiddenError403("Banned")
Use it for auth checks, rate limiting, or any precondition. AuthView overrides it to call check_auth(); APIKeyView uses it to validate the API key.
after_response
Runs after the response is built — for successes, responses from handle_exception, and 405 method-not-allowed. Return the response (mutated or replaced). Default is a no-op.
from plain.http import Response
from plain.utils.cache import patch_cache_control
class MyView(View):
def after_response(self, response: Response) -> Response:
patch_cache_control(response, private=True)
return response
Exceptions raised inside after_response are not routed through handle_exception — they escape to the framework's error renderer. Guard in before_request or inside the handler for anything that might raise.
handle_exception
Converts an exception raised during before_request or the handler into a response. Subclasses override it to format errors for their clients.
from plain.http import Response
class MyView(View):
def handle_exception(self, exc: Exception) -> Response:
if isinstance(exc, MyAppError):
return JsonResponse({"error": str(exc)}, status_code=400)
return super().handle_exception(exc)
The base View.handle_exception re-raises, so unhandled cases fall through to the framework default — which returns a plain-text status line (404 Not Found, 500 Internal Server Error, etc.). View subclasses that want a richer format override the hook: TemplateView renders {status}.html, APIView emits JSON.
ResponseException is unwrapped by get_response before handle_exception runs, so you don't need to handle it in overrides. Returning a 5xx response is treated as a real failure: the framework logs the exception once (via log_exception) and attaches it to response.exception so observability tooling can record it. Returning a 4xx response is silent — the view chose to render it as a handled outcome.
Plain's HTTP exceptions (NotFoundError404, ForbiddenError403, BadRequestError400, etc.) inherit HTTPException and carry their own status_code. Subclass HTTPException to define your own:
from plain.http import HTTPException
class PaymentRequiredError402(HTTPException):
status_code = 402
ResponseException
At any point during request handling, you can raise a ResponseException to immediately return a response. This is useful for authorization checks or rate limiting in nested helper functions.
from plain.views import View
from plain.views.exceptions import ResponseException
from plain.http import Response
class ExampleView(View):
def get(self):
if self.request.user and self.request.user.exceeds_rate_limit:
raise ResponseException(
Response("Rate limit exceeded", status_code=429)
)
return Response("ok")
Error views
Plain core's exception handler returns plain text — 404 Not Found, 500 Internal Server Error, etc. Styled error pages live in plain.templates: TemplateView.handle_exception looks for {status_code}.html and renders it with request, status_code, exception, and DEBUG in context, falling back to plain text on TemplateFileMissing.
To get a styled 404 for URL-resolution failures (where no view runs), mount NotFoundView as the last route:
from plain.templates.views import NotFoundView
from plain.urls import path
urls = [
# ... your routes ...
path("<path:_>", NotFoundView),
]
The resolver treats a sole-segment terminal <path:> as a catchall: slash-agnostic, and yields to slash-mismatch redirects from specific routes. So you still get /login → 308 → /login/ for path("login/", LoginView), not a stray 404 from the catchall.
View patterns
Don't evaluate querysets at class level
Class attributes execute at import time, not per request. Queries belong in methods.
# Bad — runs once at import, stale forever
class DashboardView(View):
recent_users = User.query.order_by("-created_at")[:5]
# Good — fresh per request
class DashboardView(View):
def get_template_context(self):
return {"recent_users": User.query.order_by("-created_at")[:5]}
Paginate list views
Always paginate querysets in list views. Unbounded queries get slower as data grows.
from plain.paginator import Paginator
def get_template_context(self):
paginator = Paginator(Item.query.all(), per_page=25)
page = paginator.get_page(self.request.query_params.get("page"))
return {"page": page}
Wrap multi-step writes in transactions
Use transaction.atomic() when creating or updating related objects together.
from plain.postgres import transaction
with transaction.atomic():
order = Order(user=user)
order.save()
payment = Payment(order=order, amount=total)
payment.save()
FAQs
How do I exempt a view from CSRF protection?
Use the CSRF_EXEMPT_PATHS setting to specify path patterns that should bypass CSRF protection. For example:
# app/settings.py
CSRF_EXEMPT_PATHS = [
r"^/api/", # Exempt all API routes
r"^/webhooks/", # Exempt webhook endpoints
]
How do I access URL parameters?
URL parameters are available via self.url_kwargs.
class ExampleView(View):
def get(self):
user_id = self.url_kwargs["id"]
return f"User ID: {user_id}"
How do I access the request object?
The request is available as self.request after the view is set up.
class ExampleView(View):
def get(self):
return f"Path: {self.request.path}"
Can I customize view initialization?
Yes, define your own __init__ method to accept custom arguments passed via as_view().
class CustomView(View):
def __init__(self, feature_enabled=False):
self.feature_enabled = feature_enabled
# In URLs
path("/custom/", CustomView.as_view(feature_enabled=True))
Installation
Views are included with the core plain package. No additional installation is required.