Views
Take a request, return a response.
- Overview
- HTTP methods map to class methods
- TemplateView
- FormView
- Object views
- RedirectView
- 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.
TemplateView
For rendering templates, use TemplateView. This is the base class for most other built-in view classes.
from plain.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
For simple pages that don't need custom context, you can configure TemplateView directly in your URL routes.
from plain.views import TemplateView
from plain.urls import path, Router
class AppRouter(Router):
routes = [
path("/example/", TemplateView.as_view(template_name="example.html")),
]
FormView
FormView handles displaying and processing forms.
from plain.views import FormView
from .forms import ExampleForm
class ExampleView(FormView):
template_name = "example.html"
form_class = ExampleForm
success_url = "." # Redirect to the same page
def form_valid(self, form):
# Do additional processing here
return super().form_valid(form)
The form is automatically available in your template as form.
{% extends "base.html" %}
{% block content %}
<form method="post">
<!-- Render general form errors -->
{% for error in form.non_field_errors %}
<div>{{ error }}</div>
{% endfor %}
<!-- Render form fields -->
<label for="{{ form.email.html_id }}">Email</label>
<input
type="email"
name="{{ form.email.html_name }}"
id="{{ form.email.html_id }}"
value="{{ form.email.value() or '' }}"
autocomplete="email"
autofocus
required>
{% if form.email.errors %}
<div>{{ form.email.errors|join(', ') }}</div>
{% endif %}
<button type="submit">Save</button>
</form>
{% endblock %}
Object views
Plain provides views for standard CRUD operations. Each requires you to implement get_object() or get_objects() to control what data is accessed.
DetailView
DetailView displays a single object.
from plain.views import DetailView
class ExampleDetailView(DetailView):
template_name = "detail.html"
def get_object(self):
return MyObjectClass.query.get(
id=self.url_kwargs["id"],
user=self.request.user, # Limit access
)
The object is available in your template as object. You can also set context_object_name for a more descriptive name.
CreateView
CreateView displays a form and creates a new object on successful submission.
from plain.views import CreateView
from .forms import CustomCreateForm
class ExampleCreateView(CreateView):
template_name = "create.html"
form_class = CustomCreateForm
success_url = "."
UpdateView
UpdateView displays a form pre-populated with an existing object and saves changes on submission.
from plain.views import UpdateView
from .forms import CustomUpdateForm
class ExampleUpdateView(UpdateView):
template_name = "update.html"
form_class = CustomUpdateForm
success_url = "."
def get_object(self):
return MyObjectClass.query.get(
id=self.url_kwargs["id"],
user=self.request.user,
)
DeleteView
DeleteView confirms deletion of an object. POST to delete, no form class needed.
from plain.views import DeleteView
class ExampleDeleteView(DeleteView):
template_name = "delete.html"
success_url = "/list/"
def get_object(self):
return MyObjectClass.query.get(
id=self.url_kwargs["id"],
user=self.request.user,
)
ListView
ListView displays a collection of objects.
from plain.views import ListView
class ExampleListView(ListView):
template_name = "list.html"
def get_objects(self):
return MyObjectClass.query.filter(
user=self.request.user,
)
The objects are available in your template as objects.
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 framework's default renders {status}.html (Error views) — most views don't need to override this hook. Override when you want a non-HTML format: APIView emits JSON. The base View.handle_exception re-raises, so unhandled cases fall through to the framework default.
ResponseException is unwrapped by get_response before handle_exception runs, so you don't need to handle it in overrides. The exception is logged once — before handle_exception is called — so overrides focus purely on response shape, not observability.
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 DetailView
from plain.views.exceptions import ResponseException
from plain.http import Response
class ExampleView(DetailView):
def get_object(self):
if self.request.user and self.request.user.exceeds_rate_limit:
raise ResponseException(
Response("Rate limit exceeded", status_code=429)
)
return AnExpensiveObject()
Error views
HTTP errors are rendered from templates named after the status code:
templates/404.html- Page not foundtemplates/403.html- Forbiddentemplates/500.html- Server error
Plain looks for {status_code}.html and renders it with request, status_code, and exception in context. If the template is missing or fails to render, a plain-text body is returned (404 Not Found, 500 Internal Server Error, etc.). Most apps only need the three templates above.
This covers every error source — exceptions raised inside views, URL resolution failures, middleware errors — so 404.html renders for any 404, not just ones raised from your own code. Views that want a different format (e.g. JSON for an API) override handle_exception to opt out.
Your 500.html template should be self-contained. Avoid extending base templates or accessing the database/session, since server errors can occur during middleware or template rendering. 404.html and 403.html can safely extend base templates since they occur during view execution after middleware runs.
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.