Django Portfolio Journal

Version 2: Improving Django views: fewer queries, smarter data fetching


Version 2 is not just a model rebuild — the views changed significantly too. The main goal was reducing the number of database queries per request and fetching only the data each view actually needs.

@cached_property: one query per request

Several views in v1 called the same query multiple times within a single request. In v2, repeated lookups are replaced with @cached_property, which executes the query once and caches the result on the view instance:

@cached_property
def profile(self):
    return get_object_or_404(
        Profile.objects.select_related("user").only(
            "id", "profession", "user__id", "user__first_name", "user__last_name"
        ),
        user__username=PORTFOLIO_USERNAME,
    )

If self.profile is accessed twice in get_context_data(), the second access reads from the instance cache — no second query. The same pattern is applied to jobs, skills counts, and achievements across the about, skills, and resume views.

A side effect of the profile change: v1 used .first(), which silently returns None if no profile exists. v2 uses get_object_or_404(), which raises a proper 404 instead of crashing later with an AttributeError in the template.

only(): fetch what you need

v1 loaded full model instances even when only two or three fields were used in the template. v2 uses only() throughout to limit the columns fetched from the database:

# v1 — loads all columns
Job.objects.filter(until_present=True, published=True)

# v2 — loads only what the view uses
Job.active_jobs.filter(until_present=True).values_list("position", "company_name")

This matters most for models with large HTMLField content — fields that are expensive to load and useless if the view never reads them.

One query, split in Python

In v1, ResumeView called get_job_data() and then filtered it three times — three round trips to the database for the same underlying data:

# v1 — three queries
context["jobs_formation"] = self.get_job_data().filter(job_type=Job.JobType.FORMATION)
context["jobs_mentoring"] = self.get_job_data().filter(job_type=Job.JobType.MENTORING)
context["jobs_experience"] = self.get_job_data().filter(job_type__in=[...])

In v2, the queryset is evaluated once into a Python list, then split by iterating:

# v2 — one query
all_jobs = list(self.jobs)
for job in all_jobs:
    if job.job_type == Job.JobTypeChoices.FORMATION:
        formation.append(job)
    elif job.job_type == Job.JobTypeChoices.MENTORING:
        mentoring.append(job)
    else:
        experience.append(job)

The same pattern applies to SkillsView, which in v1 called get_skills_data() multiple times with different category filters. In v2 all skills are loaded in one query and grouped in Python.

Prefetch to eliminate N+1 queries

In v1, JournalDetailView loaded links lazily in a loop. Each access to link.platform.name triggered a separate query if the platform was not already in cache:

# v1 — potential N+1
for link in self.object.links.all():
    platform_name = link.platform.name  # separate query per link
    ...

In v2, Prefetch with select_related and only() loads everything in one controlled query:

prefetch_links = Prefetch(
    "links",
    queryset=Link.active_links.select_related("platform")
    .only("id", "title", "url", "platform__id", "platform__name")
    .order_by(Lower("title"), "pk"),
    to_attr="links_list",
)

The same pattern is used in PortfolioDetailView, which prefetches both links and skills. Both v1 detail views loaded related data lazily — safe for small datasets, but generating one extra query per related object as the data grows.

Contact form: explicit validation and proper logging

The contact form changed in two ways.

In v1, the clean_* methods only stripped whitespace — they did not raise a ValidationError for blank fields, leaving that to the model constraint. v2 adds explicit checks so the user gets a clear field-level error message immediately:

def clean_first_name(self):
    first_name = (self.cleaned_data.get("first_name") or "").strip()
    if not first_name:
        raise ValidationError(_("Please provide a first name."))
    return first_name

In v1, send_email() used print() on failure. v2 uses the logger:

# v1
print("--- ERROR - FAILED Contact Form submission ---", ...)

# v2
logger.exception(f"Failed contact form submission for: '{email}'.")

On Render, print() output is not captured by any monitoring tool. The logger routes exceptions to Sentry, where they are tracked and trigger alerts automatically.

The view itself also simplified: v1 caught OperationalError and Exception separately and exposed the raw error string to the user. v2 catches a single Exception, logs the full traceback, and returns a generic message — keeping internal error details out of the browser.


© 2026 Dorothea Reher  ·  Impressum  ·  Privacy Policy
Designed by BootstrapMade and modified by Dorothea Reher