Liquid: a template engine that refuses to eval, and the lax parser you inherit
Liquid markup language. Safe, customer facing template language for flexible web apps.
At a glance
- What is it?
- Liquid is Shopify's template engine for templates that customers or staff edit in place, built on three constraints: readable markup, no evaluation, and a stateless split between compiling and rendering. The Ruby API is small, and nearly all of its sharp edges live in error handling.
- Who is it for?
- Liquid is the right pick when the people editing templates are your users, because the language cannot execute server code and Environments let you scope which tags and filters they can reach. It is the wrong pick if you expect a full language inside templates or automatic failure detection, since the parser defaults to :lax and undefined variables render as empty strings unless you pass strict_variables and strict_filters.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 17 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on November 13, 2026, and from our analysis. They are not legal advice.
Star history
- Shopify/liquid11,924 ★ · +33
| Repository | 2026-09-22 | 2026-11-13 |
|---|---|---|
| Shopify/liquid | 11.9k | 11.9k |
33 Stars in the last 52 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
Non-evaluating by design, and every feature you add costs a Ruby class
Liquid starts from a constraint about who is allowed to write the template. The stated requirement is that it be non evaling and secure, because Liquid templates are made so that users can edit them, and you do not want to run code on your server which your users wrote. That one decision shapes everything else, including what the language refuses to give you. Templates cannot run code of their own, so they can reach only what the engine and your application expose. The bill arrives on the application side. Any logic your editors want, a currency format, a bespoke tag, a house specific lookup, has to be written as a filter or tag in Ruby and registered before a template can reach it. In practice a feature request for a template language feature is a pull request against the host application, which is the safe trade but also the slow one. There is a readability requirement sitting next to the security one, since an engine that produces bad looking markup is no fun to use..
Compile once and render many is the whole stateless contract
The second constraint is statelessness, and it is why the API looks the way it does. Compile and render steps have to be separate so that the expensive parsing and compiling can be done once, and afterwards you render by passing in a hash with local variables and objects. Everything else follows from that split:
@template = Liquid::Template.parse("hi {{name}}") # Parses and compiles the template
@template.render('name' => 'tobi') # => "hi tobi"The same split is why the engine can advertise rendering templates directly from the database. A template kept as text in a row becomes a compiled object you hold onto, and each request supplies only data, so parsing cost is paid once per template rather than once per render. Design your integration around that: the compiled Liquid::Template instance is the unit you cache, and the hash you pass to render is the only part that varies.
The default parser is lax, and strict2 is stricter than strict
Parsing is deliberately forgiving by default, which is the setting most teams inherit and the one that surprises them. The parser is normally very lax and will accept almost anything without error, and the project's own note is that this makes it very hard to debug and can lead to unexpected behaviour. Four modes are available, and their semantics are not interchangeable:
Liquid::Environment.default.error_mode = :strict2 # Raises a SyntaxError when invalid syntax is used in all tags
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.Read the first two lines twice: :strict covers invalid syntax in some tags, :strict2 covers all tags, and :warn records the problem in template.errors and carries on. Warning mode has its own trap, since the problem is recorded only as an entry in template.errors while rendering continues as normal, so nothing is raised unless your application checks that array. You can also pass error_mode: :strict to a single parse call, which is the move when you want strictness only inside a theme editor. The migration caveat is real: templates already accepted under :lax can start raising SyntaxError the day you turn this on, so budget for a pass over existing content.
Undefined variables stay silent until you ask, and a bad filter blanks the expression
The renderer is quieter than the parser. By default it does not raise or in any other way notify you if some variables or filters are missing, meaning anything not passed to render simply vanishes from the output. Passing strict_variables: true and strict_filters: true changes that, and the errors are stored in the errors array of the Liquid::Template instance rather than raised:
template = Liquid::Template.parse("{{x}} {{y}} {{z.a}} {{z.b}}")
template.render({ 'x' => 1, 'z' => { 'a' => 2 } }, { strict_variables: true })
#=> '1 2 ' # when a variable is undefined, it's rendered as nil
template.errors
#=> [#<Liquid::UndefinedVariable: Liquid error: undefined variable y>, #<Liquid::UndefinedVariable: Liquid error: undefined variable b>]The filter case is the rougher edge. When at least one filter in a chain is undefined, the whole expression renders as nil, so a single wrong filter name erases the entire value instead of degrading gracefully:
template = Liquid::Template.parse("{{x | filter1 | upcase}}")
template.render({ 'x' => 'foo' }, { strict_filters: true })
#=> '' # when at least one filter in the filter chain is undefined, a whole expression is rendered as nil
template.errors
#=> [#<Liquid::UndefinedFilter: Liquid error: undefined filter filter1>]Because collection is opt in, the default path stays quiet in production. If you would rather stop at the first problem, render! raises that first exception instead of accumulating everything into errors.
Environments scope custom tags, but the default environment is global
Custom tags and filters attach to an Environment, which is the mechanism that stops one set of templates from reaching another set's functionality. Liquid::Environment.build takes a block, register_tag attaches a tag class, and the parse call receives the environment as an option:
user_environment = Liquid::Environment.build do |environment|
environment.register_tag("renderobj", RenderObjTag)
end
Liquid::Template.parse(<<~LIQUID, environment: user_environment)
{% renderobj src: "path/to/model.obj" %}
LIQUIDThe same shape appears again for a separate email context that registers unsubscribe_footer on its own environment, so those two template sets cannot see each other's tags. The stated reasons are encapsulation of logic, avoiding clashes between custom tags, easier maintenance, and limiting the tags and filters available in a given context. The caveat is Liquid::Environment.default, offered for smaller projects, which is global by nature: anything registered there is reachable from every template parsed without an explicit environment. Isolation is opt in per parse call, so one missing keyword argument silently reopens the surface you believed you had closed.
The syntax reference is not in the checkout, only a 12 line HTML example
The repository sends you outward for the language itself: Liquid documentation from Shopify at shopify.dev, a wiki hosted on GitHub, a website at liquidmarkup.org, and a History.md file for version changes. The only template example in the README is this short block:
<ul id="products">
{% for product in products %}
<li>
<h2>{{ product.name }}</h2>
Only {{ product.price | price }}
{{ product.description | prettyprint | paragraph }}
</li>
{% endfor %}
</ul>That example carries more weight than its length suggests, because it shows the shape of the entire language: a for loop over a collection, output tags with a dotted path into an object, and a filter chain of price, prettyprint, and paragraph. It also shows how little logic a template carries, since there is no inline arithmetic and control flow appears only as the loop. A team holding only the gem therefore needs one of those external references to learn which tags and filters the current release exposes, and you should assume the docs site can describe features a given pinned version does not have.
Ordinary Ruby tooling around a five minor release line
The gem surface is small and the surrounding repository is conventional Ruby. The top level holds lib/, bin/, spec/, test/, performance/, example/server/, a Rakefile, a Gemfile, liquid.gemspec, and a .ruby-version, with .rubocop.yml sitting next to .rubocop_todo.yml, which tells you lint debt is tracked deliberately rather than ignored. Having both spec/ and test/ means you cannot assume a single framework when you go looking for how a behaviour is covered, and example/server/ holds a server example. Installing is a single Gemfile line, gem 'liquid', and the API centers on Liquid::Template, where you pass parse the content of a file and call render with a parameters hash. Releases move in small steps: v5.11.0 on 2025-11-19, v5.12.0 on 2026-03-18, and v5.13.0 on 2026-06-29, with the default branch last pushed to on 2026-10-27 under the MIT license. Pinning the version in your Gemfile is how you keep a rendering change from arriving unannounced.
Editorial conclusion
Liquid is the right pick when the people editing templates are your users, because the language cannot execute server code and Environments let you scope which tags and filters they can reach. It is the wrong pick if you expect a full language inside templates or automatic failure detection, since the parser defaults to :lax and undefined variables render as empty strings unless you pass strict_variables and strict_filters. Before shipping, set :strict or :warn on new apps, register every custom tag on a scoped Environment instead of the default one, and pin your gem version, given minor releases such as v5.13.0 landing on 2026-06-29.
Frequently asked questions
Is Liquid safe for templates your users edit?
That is the design constraint. Liquid is required to be non evaling and secure, precisely because the templates are meant to be edited by users without running code on your server. The tradeoff is that any extra capability has to be added as a tag or filter you register in Ruby, since the grammar exposes nothing you have not provided.
What is the difference between render and render! in Liquid?
render pushes undefined variable and filter errors into the errors array of the Liquid::Template instance when strict_variables or strict_filters are set to true, while render! raises on the first exception instead. Given a template of {{x}} {{y}} rendered with only x defined, render! under strict_variables raises Liquid::UndefinedVariable.
Which Liquid error mode should a new app use?
The parser defaults to :lax, which accepts almost anything, so the guidance is to enable :strict or :warn on new apps and to use one of them in the template editors of existing apps so editors get better error messages. :strict raises a SyntaxError for invalid syntax in some tags, :strict2 for all tags, and :warn records the error in template.errors and continues.
How do I add a custom tag to a Liquid template?
Register it on an Environment and pass that environment to parse, as in Liquid::Environment.build with environment.register_tag("renderobj", RenderObjTag) followed by Liquid::Template.parse(source, environment: user_environment). Smaller projects can use Liquid::Environment.default, which is global, so anything registered there is visible to templates parsed without an explicit environment.
How do I install the Liquid gem?
Add gem 'liquid' to your gemfile. The API is built around Liquid::Template: for standard use you pass parse the content of a file and call render with a parameters hash of local variables, so compiled templates and data can be kept separate.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/shopify-liquid)Star history chart for your README
[](https://hysenlabs.com/projects/shopify-liquid)Was this useful?