The difference between Shopify’s render and include tags is variable scope. Both output a snippet. An included snippet can read and overwrite every variable in the file that called it. A rendered snippet sees global objects, the objects its template already exposes, and the parameters you pass, and nothing it assigns leaks back out. So a mechanical find-and-replace from include to render silently blanks any value the snippet used to pick up from its caller.
Shopify’s include reference marks the tag deprecated “because the way that variables are handled reduces performance and makes code harder to both read and maintain”, and Theme Check’s DeprecatedTag check warns on every use. The check’s own docs note that the tag “is still used in themes”. Changing the tag is a one-word edit per call site. The work is finding what each snippet was quietly depending on.
We ran every Liquid example below on the open-source Shopify Liquid gem (liquid 5.14.0, Ruby 3.2.2), and every Theme Check result on @shopify/theme-check-node 4.0.0 called through its JavaScript API against a scratch theme. The gem is Shopify’s open-source Liquid engine, not the renderer behind a live storefront, and the Theme Check package may not be the version bundled inside Shopify CLI. Where the gem’s behaviour matched the render reference, we say so.
Shopify render vs include: what actually changes?
Take a snippet that prints a currency code and then reassigns it:
{%- comment -%} snippets/price.liquid {%- endcomment -%}
[price sees currency=<{{ currency }}>]{% assign currency = 'EUR' %}
Called from a section that has already set currency to 'GBP':
{% assign currency = 'GBP' %}
{% render 'price' %} after=<{{ currency }}>
{% include 'price' %} after=<{{ currency }}>
The render line printed [price sees currency=<>] after=<GBP>. Inside the snippet the variable did not exist, and the caller’s copy was untouched. The include line printed [price sees currency=<GBP>] after=<EUR>: the snippet read the caller’s variable and then changed it for the rest of the file. Order matters in that test. With the include first, the render line also reported EUR, because the include had already rewritten the caller’s variable.
Passing it as a parameter fixes the read. {% render 'badge', label: label %} showed the value inside the snippet. Reassigning label in there left the caller’s label as it was, which is what the render docs promise: “Any changes that are made to a passed variable apply only within the snippet.”
Global objects are the exception. The render reference says snippets can still reach global objects and anything “directly accessible outside the snippet”, such as product on a product template or section inside a section. We did not test that on a real store; the gem has no Shopify globals to check against.
Which include patterns break when you switch?
These are the four patterns we reproduced.
Loop variables. This is the common one on collection grids:
{% for product in collection.products %}
{% include 'product-card' %}
{% endfor %}
With include, the snippet sees the loop’s product. Change it to a bare render and the gem printed an empty card for every item. Pass it explicitly with {% render 'product-card', product: product %}. Or use the for parameter, {% render 'product-card' for collection.products as product %}, which also gives the snippet its own forloop object. That second form matters if the card reads forloop, for example to eager-load the first few images. An included card saw the outer loop’s forloop.index; a card rendered with product: product inside the same loop got a blank one; the for ... as form printed 1 and 2 again.
Snippets that hand a value back. A snippet whose only job is to assign something the caller uses later, such as a discount percentage, stops working. Under render that assignment dies with the snippet. If the snippet can print the value instead, capture its output:
{%- comment -%} snippets/discount-pct.liquid prints: {{ compare | minus: price | times: 100 | divided_by: compare }} {%- endcomment -%}
{% capture discount_pct %}{% render 'discount-pct', price: product.price, compare: product.compare_at_price %}{% endcapture %}
{{ discount_pct | strip }}% off
With a price of 7500 and a compare-at price of 10000 that printed 25% off. The strip matters, because any whitespace the snippet emits ends up in the captured string.
Dynamic snippet names. include accepts a variable, so {% include section.settings.icon %} works. render does not. The gem refused to parse {% render name %} with Syntax error in tag 'render' - Template name must be a quoted string. Replace these with a case block that renders each quoted name:
{% case section.settings.icon %}
{% when 'icon-cart' %}{% render 'icon-cart' %}
{% when 'icon-heart' %}{% render 'icon-heart' %}
{% endcase %}
Nested includes. The render reference states that inside a rendered snippet “you can’t use the include tag”. When we rendered a snippet that itself contained {% include 'price' %}, the gem output Liquid error: include usage is not allowed in this context in place of the inner snippet. Convert from the leaves up: the deepest snippets first, then their callers.
with carries over unchanged. {% render 'card' with product %} exposed the object under the snippet’s filename (card), and with product as item exposed it as item, matching the docs.
How do you find every variable a snippet depends on?
Reading every snippet by eye stops scaling quickly. Theme Check will list the dependencies for you, but only once each snippet has a LiquidDoc header.
The UndefinedObject check reports variables that are used before being assigned, captured or declared with @param. Its docs say the check “is skipped for snippet files unless it contains LiquidDoc”. We confirmed both halves. A snippet printing {{ amount }} and {{ currency }} with no header produced no offence at all. Add a header that declares only amount:
{% doc %}
@param {number} amount - Price in the smallest currency unit
{% enddoc %}
<span class="price">{{ amount | money }} {{ currency }}</span>
and Theme Check raised UndefinedObject on line 4: Unknown object 'currency' used. That warning is your list of hidden dependencies. Declare each one as a @param, and the MissingRenderSnippetArguments check then flags every render call that does not pass it. On our scratch theme, {% render 'price', amount: amount %} produced Missing required argument 'currency' in render tag for snippet 'price'. Our LiquidDoc guide covers the header syntax and the type checks in more depth.
Two traps showed up in the same runs.
First, do not hunt for includes with grep alone. A pattern such as grep -rnE '\{%-?[[:space:]]*include[[:space:]]' found the tag in a section file and missed this one in a snippet:
{%- liquid
assign currency = 'GBP'
include 'price'
-%}
Inside a {% liquid %} block there are no {% delimiters per line. Theme Check’s DeprecatedTag reported both.
Second, treat UnusedAssign warnings with suspicion until the includes are gone. In a section that assigned currency and then read it only through {% include 'price' %}, Theme Check 4.0.0 still reported The variable 'currency' is assigned but not used. The include was the only reader. Delete that “unused” assign before converting and you break the snippet; convert first and the warning becomes accurate.
Should you convert every include now?
On a theme you maintain, yes, one snippet at a time. Add a LiquidDoc header and fix what UndefinedObject reports. Switch the call sites to render with the parameters MissingRenderSnippetArguments asks for. Then read the UnusedAssign list again. Work from the leaf snippets up, so you never render something that still includes.
The behaviour change is invisible in a diff, and for the scope cases the failure is an empty string rather than an error, so every converted snippet needs the templates that use it checked by eye. That is cheaper folded into theme work that already needs a test pass than shipped as a large conversion on its own. To stop new includes creeping back in, run Theme Check in CI; our Theme Check CI guide covers making warnings fail the build.
If the theme is a purchased one you plan to replace within a few months, leave the includes alone. They still render (DeprecatedTag’s default severity is a warning, not an error), and the effort is better spent on the new theme.
We do this kind of theme clean-up as part of our Shopify development work, often alongside a performance pass or a move to Online Store 2.0 sections.