How resolution works¶
Every page request runs the same pipeline: collect metadata from several providers, merge the layers in precedence order, and finalize the result into a renderable object. Understanding this model makes every other feature easier to reason about.
The precedence ladder¶
Providers are merged from lowest to highest priority. A higher-priority provider wins per field — except for JSON-LD and extra head fragments, which accumulate from all layers.
| Priority | Source | Typical use |
|---|---|---|
| 10 | SEO_SUITE["DEFAULTS"] |
Site-wide fallback title suffix, robots, og:type |
| 20 | SEO_SUITE["SITE_DEFAULTS"][site_id] |
Per-site defaults on multi-site deployments |
| 30 | SeoPath row matching the request path |
Admin-editable overrides for specific URLs |
| 40 | Object's get_seo_metadata() or SeoObject row |
Model fields, or admin overrides for 3rd-party models |
| 50 | View's get_seo_metadata() / seo_* attrs |
View-level overrides and enhancements |
Three-state fields: UNSET vs None vs a value¶
Each metadata field can be in one of three states:
- UNSET: "this provider has no opinion". The resolver skips this layer and uses whatever came from a lower-priority provider.
Noneor"": "explicitly empty". This wins over lower-priority values and renders nothing. Use this when you want to suppress a field that a lower layer would otherwise emit (for example, a page that should have nometa description).- A string value: emitted as-is.
In practice you rarely think about this directly. The mixins and column-based providers set fields to UNSET when a model field is blank, so lower-priority values naturally survive.
Field merging rules¶
| Field type | Merge behaviour |
|---|---|
Scalars (title, meta_description, robots, canonical_url, ...) |
Higher wins; UNSET defers |
og dict, twitter dict |
Deep-merged key by key; higher wins per key |
hreflang list |
Replaced wholesale when the higher layer provides a list |
jsonld list, extra_head list |
Accumulated — all layers contribute |
The JSON-LD accumulation rule is intentional: a view that adds a BreadcrumbList
profile sits alongside a model that adds an Article profile; the page emits
both <script type="application/ld+json"> blocks.
Auto-population of og / twitter fields¶
After merging, the finalizer fills gaps in the og and twitter dicts using
the resolved page-level metadata:
og:title←title(ifog:titlenot set)og:description←meta_description(if not set)og:url←canonical_url(if not set)og:image←og_image(if not set)twitter:*fields mirror the correspondingog:*fieldstwitter:carddefaults to"summary_large_image"when an image is present, otherwise"summary"
You can override any of these by explicitly setting the og or twitter dicts
at any layer.
A concrete example¶
Suppose the settings define a title suffix and global robots, a SeoPath row
sets a custom title for /about/, and the view sets a description:
Layer 10 (DEFAULTS): title_suffix=" | Acme", robots="index,follow"
Layer 30 (SeoPath): title="About Acme"
Layer 50 (View): meta_description="We build great things."
The merged result:
title = "About Acme" (from layer 30)
title_suffix = " | Acme" (from layer 10)
meta_description = "We build great things." (from layer 50)
robots = "index,follow" (from layer 10)
Rendered title tag: <title>About Acme | Acme</title>
What the resolver touches¶
The resolver is called once per request (memoized on the request object). It:
- Collects all layers in priority order.
- Merges them with
SeoMetadata.merge_all. - Absolutises the canonical URL using the request host or
CANONICAL_DOMAIN. - Returns a
FinalizedSeoMetadataobject. No UNSET sentinels remain; all fields are render-ready.
If SEO_SUITE["CACHE_TTL"] is greater than 0, the finalized result is stored
in Django's cache keyed by site:language:app.model:pk:generation. The
generation counter is bumped automatically on model save/delete, so stale cache
entries are never served.
Reading the resolved metadata in code¶
If you need to inspect the resolved metadata outside a template (for example,
in a REST endpoint or a management command), use resolve_seo directly:
from seo_suite.context import resolve_seo
def my_view(request):
seo = resolve_seo(request)
print(seo.title)
print(seo.canonical_url)
print(seo.jsonld)
For the common case inside a class-based view that already uses SeoViewMixin,
the metadata is already in context["seo"] and on request.seo after
get_context_data runs.