Liquid Templating - Variables, Filters, Tags, and the Data Flow
Module 2 · Chapter 2 - The Jekyll model: Layouts, Liquid, and content
What you’ll learn
- The two syntactic forms in Liquid - output
{{ }}and logic{% %}- and when to reach for each. - The global objects Jekyll exposes (
site,page,layout,content) and the shape of each. - The filters you’ll reach for daily:
date,slugify,where,where_exp,group_by,sort,default,escape,relative_url. - Control flow you’ll actually use -
forwithlimit/offset/reversed, plusif/unless/case. - How to debug Liquid in a static-build world without a runtime debugger.
Concepts
Liquid is a Shopify-designed template language that Jekyll uses for every template, layout, include, and even some plugin-rendered files. It has exactly two pieces of syntax. Output tags - {{ expression }} - evaluate an expression and write it into the rendered HTML. Logic tags - {% for x in xs %}, {% if cond %}, {% assign y = ... %} - control the render without producing output themselves. Everything else in your template is literal text that passes through unchanged.
Jekyll injects a handful of globals into every render. site is the merged view of _config.yml, every collection, and _data/. page is the front matter of whatever file is currently rendering, plus a few derived fields like page.url, page.path, and page.excerpt. Inside a layout, content is the rendered child. The Jekyll variables docs enumerate the full shape. The mental model: site is global, page is local, and Liquid never mutates either - assign only creates new variables in the current scope.
Filters are how you transform values inline. The syntax is pipe-style: {{ post.date | date: "%-d %b %Y" }}. Most filters take the value on their left and return a new one - they don’t mutate. The handful you’ll touch every week: date (format a date), slugify (kebab-case an arbitrary string), default (fall back when a value is nil or empty), escape (HTML-escape), and the URL pair relative_url/absolute_url. Jekyll also adds collection-flavoured filters on top of stock Liquid: where (filter an array by a key/value), where_exp (filter with a Liquid expression), group_by (turn an array into named buckets), and sort (by key, ascending). The Jekyll Liquid reference lists the full set.
Control flow is small but exact. for is your workhorse - it iterates an array and exposes forloop.index, forloop.first, and forloop.last so you can decorate first and last items. for also accepts limit:, offset:, and reversed, which compose cleanly: {% for post in site.posts limit:5 reversed %}. if/unless are conditional blocks; case/when is a switch. There is no else if - Liquid spells it elsif.
Debugging Liquid in a static-build world means you don’t have a REPL. You have three reliable tools. First, dump the variable: <pre>{{ some_var | jsonify }}</pre> will serialise most structures readably. Second, when you need to render Liquid syntax literally (when teaching or troubleshooting), wrap it in a raw block - the block tells Liquid “leave my content alone”. The raw tag itself is hard to print on a page rendered by Liquid; the reliable trick is to emit the literal text through an output tag, e.g. the-text-you-want-printed. Third, watch the build output - jekyll serve --trace will print the stack on Liquid exceptions, which is much more useful than the default one-liner. The popular suggestion of an inspect filter is not standard Liquid; jsonify is the dependable Jekyll-flavoured equivalent.
Walkthrough
A homepage that lists the five most recent posts, with the first one highlighted:
---
layout: default
---
<section class="recent">
{% for post in site.posts limit:5 %}
<article class="card {% if forloop.first %}card--featured{% endif %}">
<h2><a href="{{ post.url | relative_url }}">{{ post.title }}</a></h2>
<time datetime="{{ post.date | date_to_xmlschema }}">
{{ post.date | date: "%-d %b %Y" }}
</time>
<p>{{ post.excerpt | strip_html | truncate: 160 }}</p>
</article>
{% endfor %}
</section>
The interesting moves: site.posts is pre-sorted newest first; limit:5 slices in template-space without needing an assign; forloop.first lets the first card take a different class; date_to_xmlschema produces a machine-readable timestamp for the datetime attribute while a human-readable date shows in the body.
A tag index page that groups every post by its first tag and renders an anchored section per tag:
---
layout: default
title: "Tags"
permalink: /tags/
---
{% assign by_tag = site.posts | group_by_exp: "post", "post.tags | first" %}
{% for group in by_tag %}
<section id="tag-{{ group.name | slugify }}">
<h2>{{ group.name | default: "Untagged" }}</h2>
<ul>
{% for post in group.items %}
<li>
<a href="{{ post.url | relative_url }}">{{ post.title }}</a>
<small>{{ post.date | date: "%Y-%m-%d" }}</small>
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
group_by_exp lets the grouping key be an expression - here, the first tag of each post - rather than a simple key. default: "Untagged" rescues posts with no tags. slugify turns the tag name into a safe id anchor.
A debugging trick - render any value as JSON when something’s off:
<pre style="white-space: pre-wrap">{{ page | jsonify }}</pre>
Drop that into a layout during development and you’ll see the full page object the template is working with. Remove it before deploying.
How it fits together
flowchart LR
cfg[_config.yml] --> site[site object]
data[_data/*.yml] --> site
posts[_posts/*.md] --> site
fm[front matter] --> page[page object]
body[Markdown body] --> page
site --> liquid[Liquid render]
page --> liquid
liquid --> html[HTML output]
site and page are the two inputs Liquid has; everything else is just expressions over them.
Common pitfalls
| Pitfall | Why it happens | Fix |
|---|---|---|
{% if x = 1 %} always renders. |
Liquid uses ==, not =. = is for assign. |
Use {% if x == 1 %}. |
date filter produces gibberish. |
You’re piping a string the parser can’t read. | Ensure it’s a Date (page.date is, "2026-01-15" is not unless you | date: "%s" it first). |
where returns nothing. |
Wrong key name or strict comparison (string vs int). | Compare types via where_exp: "p", "p.draft != true" for boolean checks. |
| Code samples render as Liquid tags, not literal text. | You wrote {{ var }} in a tutorial post. |
Wrap examples in a raw block so Liquid leaves them alone. |
Adding a key to _config.yml doesn’t show up. |
Jekyll only reads _config.yml at startup. |
Restart jekyll serve after editing _config.yml. |
Exercises
- Write a sidebar include that lists the three most recent posts, but skips the post currently being viewed. Hint: filter with
where_expagainstpage.url. - On a tag archive page, render the count of posts per tag next to the heading. Combine
group_bywithgroup.items | size. - Build a debug include
{% include debug.html var=page %}that dumps any value as JSON. Drop it into a stuck template and use it to figure out why awhereis returning empty.
Recap & next
- Output tags emit, logic tags don’t - that’s the whole grammar.
siteandpageare the global inputs; filters transform them inline.- Reach for
where,where_exp,group_by,sort, anddefaultconstantly; the date and URL filters daily. - Debug by dumping with
jsonifyand reading--traceoutput - there is no Liquid REPL.
Next, The _posts collection and designing your permalink structure - applying the Liquid model to the most important built-in collection on your site.
Check your understanding
Answer the questions below to test what you just read. You can change answers and resubmit; your best score is saved on this device.
Best score so far: /