Liquid Fundamentals for Theme Developers Intermediate 10 min read

Common Pitfalls: Nil Checks, Whitespace, Performance

Avoid the most common Liquid mistakes by learning proper nil checking, whitespace control, and performance optimization techniques.

Every Liquid developer makes the same mistakes when starting out. Learning to avoid these pitfalls will save you hours of debugging and make your themes more reliable. Let’s cover the most common issues and how to handle them.

Nil and Empty Checks

The Problem with Nil

When you access a property that doesn’t exist, Liquid returns nil. This doesn’t cause an error, but it can lead to unexpected behavior:

{# If product.featured_image is nil... #}
{{ product.featured_image.alt }}
{# Outputs nothing, but no error #}
{{ product.featured_image | image_url: width: 400 }}
{# Error! Can't apply image_url to nil #}

Always Check Before Accessing

{# Bad: assumes featured_image exists #}
<img src="{{ product.featured_image | image_url: width: 400 }}">
{# Good: check first #}
{% if product.featured_image %}
<img src="{{ product.featured_image | image_url: width: 400 }}">
{% endif %}

Nil vs Empty String vs Blank

These are three different things in Liquid:

{% assign nil_value = nonexistent_variable %}
{% assign empty_string = "" %}
{% assign blank_value = " " %}
{{ nil_value == nil }} {# true #}
{{ empty_string == "" }} {# true #}
{{ empty_string == nil }} {# false! #}

Use blank to check for nil, empty string, and whitespace-only strings:

{# Checks for nil, "", and " " #}
{% if product.description == blank %}
<p>No description available.</p>
{% endif %}

The default Filter

Provide fallback values for potentially nil properties:

{{ product.featured_image.alt | default: product.title }}
{{ customer.first_name | default: "Guest" }}
{{ section.settings.heading | default: "Featured Products" }}

Caution: default only applies when the value is nil, false, or empty. It doesn’t apply to zero or whitespace-only strings:

{{ 0 | default: 10 }} {# 0, not 10! #}
{{ false | default: true }} {# true #}
{{ "" | default: "fallback" }} {# "fallback" #}
{{ " " | default: "fallback" }} {# " " (spaces), not "fallback"! #}

Safe Property Access Pattern

For deeply nested properties, check each level:

{# Dangerous: could fail at any level #}
{{ product.metafields.custom.size_guide.value }}
{# Safe: check each level #}
{% if product.metafields.custom.size_guide %}
{{ product.metafields.custom.size_guide.value }}
{% endif %}
{# Or use multiple conditions #}
{% assign size_guide = product.metafields.custom.size_guide %}
{% if size_guide and size_guide.value %}
{{ size_guide.value }}
{% endif %}

Truthy/Falsy Gotchas

Zero is Truthy

This trips up developers from other languages:

{% assign quantity = 0 %}
{% if quantity %}
Quantity: {{ quantity }}
{% endif %}
{# This WILL render! 0 is truthy in Liquid #}

Check explicitly for zero:

{% if quantity > 0 %}
Quantity: {{ quantity }}
{% endif %}
{# Or #}
{% if quantity != 0 %}
Quantity: {{ quantity }}
{% endif %}

Empty Arrays are Truthy

{% assign empty_array = "" | split: "," %}
{% if empty_array %}
{# This WILL render! #}
{% endif %}
{# Check size instead #}
{% if empty_array.size > 0 %}
{# Only renders with items #}
{% endif %}
{# Or use the else clause in for loops #}
{% for item in empty_array %}
{{ item }}
{% else %}
No items found.
{% endfor %}

Empty Strings are Falsy

{% assign empty = "" %}
{% if empty %}
{# This will NOT render #}
{% endif %}

But whitespace-only strings are truthy:

{% assign spaces = " " %}
{% if spaces %}
{# This WILL render! #}
{% endif %}
{# Use blank instead #}
{% if spaces == blank %}
{# Now this handles whitespace-only strings #}
{% endif %}

Try it live: Test nil checks, truthy/falsy values, and the default filter in our Liquid Playground—a safe environment to experiment without breaking your theme.

Whitespace Issues

Extra Whitespace in Output

Liquid tags add whitespace to your HTML:

<ul>
{% for item in items %}
<li>{{ item.title }}</li>
{% endfor %}
</ul>

Outputs:

<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>

Use whitespace control:

<ul>
{%- for item in items -%}
<li>{{ item.title }}</li>
{%- endfor -%}
</ul>

When Whitespace Matters

Whitespace is significant for inline elements:

{# Problem: unwanted space between elements #}
<span class="price">
{{ product.price | money }}
</span>
<span class="currency">
{{ shop.currency }}
</span>
{# Output: "$19.99 USD" with potential extra spacing #}

Fix with whitespace control:

<span class="price">
{{- product.price | money -}}
</span>
<span class="currency">
{{- shop.currency -}}
</span>

Whitespace in Variables

assign and capture preserve whitespace:

{% capture message %}
Hello, {{ customer.first_name }}!
{% endcapture %}
{{ message }}
{# Includes leading/trailing newlines #}
{# Better: #}
{%- capture message -%}
Hello, {{ customer.first_name }}!
{%- endcapture -%}
{{ message | strip }}

Performance Pitfalls

Repeated Object Access

Each dot notation access has a cost. Cache repeated values:

{# Inefficient: accessing product.featured_image 4 times #}
{% if product.featured_image %}
<img
src="{{ product.featured_image | image_url: width: 400 }}"
alt="{{ product.featured_image.alt }}"
width="{{ product.featured_image.width }}"
>
{% endif %}
{# Better: assign once #}
{% assign image = product.featured_image %}
{% if image %}
<img
src="{{ image | image_url: width: 400 }}"
alt="{{ image.alt }}"
width="{{ image.width }}"
>
{% endif %}

Unnecessary Loops

Don’t loop when you can use first, last, or array filters:

{# Inefficient: looping to find first item #}
{% for product in collection.products %}
{% if forloop.first %}
{{ product.title }}
{% endif %}
{% endfor %}
{# Better: direct access #}
{{ collection.products.first.title }}
{# Inefficient: looping to get just titles #}
{% assign titles = "" %}
{% for product in collection.products %}
{% assign titles = titles | append: product.title | append: ", " %}
{% endfor %}
{# Better: use map filter #}
{% assign titles = collection.products | map: "title" | join: ", " %}

Nested Loop Explosion

Be cautious with nested loops:

{# Dangerous: 50 products × 50 products = 2,500 iterations #}
{% for product in collection.products %}
{% for other in collection.products %}
{% if product.id != other.id %}
{# Compare every product to every other product #}
{% endif %}
{% endfor %}
{% endfor %}

If you need to compare items, try to limit the scope:

{% for product in collection.products limit: 10 %}
{% for other in collection.products limit: 10 offset: forloop.index %}
{# Limited comparison #}
{% endfor %}
{% endfor %}

Heavy Operations in Loops

Move expensive operations outside loops when possible:

{# Inefficient: calculating discount threshold in every iteration #}
{% for product in collection.products %}
{% assign threshold = settings.sale_threshold | times: 100 %}
{% if product.price < threshold %}
<span class="sale">On Sale</span>
{% endif %}
{% endfor %}
{# Better: calculate once #}
{% assign threshold = settings.sale_threshold | times: 100 %}
{% for product in collection.products %}
{% if product.price < threshold %}
<span class="sale">On Sale</span>
{% endif %}
{% endfor %}

Debugging Techniques

Using the JSON Filter

The json filter is your best debugging friend:

{# See what an object contains #}
<pre>{{ product | json }}</pre>
{# Check section settings #}
<pre>{{ section.settings | json }}</pre>
{# Debug a variable #}
<pre>{{ my_variable | json }}</pre>

Wrap in a check so it only shows in development:

{% if request.design_mode %}
<pre>{{ product | json }}</pre>
{% endif %}

Checking Variable Types

{# Is it nil? #}
{% if variable == nil %}
Variable is nil
{% endif %}
{# Is it blank? #}
{% if variable == blank %}
Variable is blank (nil, "", or empty)
{% endif %}
{# Is it a specific type? #}
{% if variable.size %}
Variable is probably an array or string
{% endif %}
{# Does it have a property? #}
{% if variable.title %}
Variable has a title property
{% endif %}

Comment Debugging

When something isn’t rendering, use comments to isolate the issue:

{% comment %} DEBUG: Starting product card {% endcomment %}
{% comment %} DEBUG: product exists: {{ product | json }} {% endcomment %}
{% if product %}
{% comment %} DEBUG: Inside if block {% endcomment %}
{{ product.title }}
{% else %}
{% comment %} DEBUG: Product was falsy {% endcomment %}
{% endif %}

Common Error Patterns

”undefined method” Errors

Usually means you’re calling a filter or property on nil:

{# Error: undefined method 'image_url' for nil #}
{{ product.featured_image | image_url: width: 400 }}

Fix: Check for nil first.

Missing Output

If nothing renders, check:

  1. Is the variable nil?
  2. Is the condition false?
  3. Is the loop empty?
{% if collection.products.size > 0 %}
{% for product in collection.products %}
{{ product.title }}
{% else %}
No products (loop is empty)
{% endfor %}
{% else %}
Collection has no products
{% endif %}

Infinite Loops (Rare)

Shopify has protections, but avoid recursive patterns:

{# Don't do this #}
{% assign counter = counter | plus: 1 %}
{% if counter < 10 %}
{% render 'same-snippet' %}
{% endif %}

Practical Checklist

Before shipping code, verify:

  • All object access has nil checks where needed
  • Empty arrays are handled (use else in for loops)
  • Prices use the money filter
  • User content uses escape in HTML attributes
  • Whitespace is controlled in inline elements
  • Expensive operations are outside loops
  • Nested loops have reasonable limits
  • Default values are provided for optional settings

Practice Exercise

Debug this problematic product card:

{# This code has multiple issues. Can you spot them? #}
<article class="product-card">
<img src="{{ product.featured_image | image_url: width: 400 }}"
alt="{{ product.featured_image.alt }}">
<h3>{{ product.title }}</h3>
<p class="price">
{% if product.compare_at_price %}
<del>{{ product.compare_at_price }}</del>
{% endif %}
{{ product.price }}
</p>
{% for tag in product.tags %}
{% assign threshold = settings.featured_threshold %}
{% if tag == "featured" and forloop.index < threshold %}
<span class="badge">Featured</span>
{% endif %}
{% endfor %}
</article>

Fixed version:

<article class="product-card">
{%- if product.featured_image -%}
<img
src="{{ product.featured_image | image_url: width: 400 }}"
alt="{{ product.featured_image.alt | default: product.title | escape }}"
loading="lazy"
>
{%- endif -%}
<h3>{{ product.title }}</h3>
<p class="price">
{%- if product.compare_at_price > product.price -%}
<del>{{ product.compare_at_price | money }}</del>
{%- endif -%}
{{ product.price | money }}
</p>
{%- assign threshold = settings.featured_threshold | default: 3 -%}
{%- for tag in product.tags -%}
{%- if tag == "featured" and forloop.index <= threshold -%}
<span class="badge">Featured</span>
{%- break -%}
{%- endif -%}
{%- endfor -%}
</article>

Issues fixed:

  1. Added nil check for featured_image
  2. Added | default: product.title | escape for alt text
  3. Added | money filter to prices
  4. Changed compare_at_price check to compare against price
  5. Moved threshold assignment outside loop
  6. Added default value for threshold
  7. Added break after finding featured tag (only need one badge)
  8. Added whitespace control throughout
  9. Changed < to <= for threshold comparison

Key Takeaways

  1. Always check for nil before accessing nested properties
  2. Use blank instead of checking for empty string (handles nil too)
  3. Zero and empty arrays are truthy in Liquid
  4. Control whitespace with {%- and -%} for clean output
  5. Cache repeated object access in variables
  6. Keep expensive operations outside loops
  7. Use | json for debugging objects and variables
  8. Add default values to prevent nil issues

What’s Next?

Congratulations! You’ve completed Module 3: Liquid Fundamentals. You now have a solid foundation in Liquid syntax, scope, control flow, snippets, and debugging.

In the next module, you’ll learn about Templates, Layouts, and the Rendering Pipeline to understand how Shopify assembles your theme into complete pages.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...