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
defaultfilter 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:
- Is the variable nil?
- Is the condition false?
- 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
elsein for loops) - Prices use the
moneyfilter - User content uses
escapein 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:
- Added nil check for
featured_image - Added
| default: product.title | escapefor alt text - Added
| moneyfilter to prices - Changed compare_at_price check to compare against price
- Moved threshold assignment outside loop
- Added default value for threshold
- Added
breakafter finding featured tag (only need one badge) - Added whitespace control throughout
- Changed
<to<=for threshold comparison
Key Takeaways
- Always check for nil before accessing nested properties
- Use
blankinstead of checking for empty string (handles nil too) - Zero and empty arrays are truthy in Liquid
- Control whitespace with
{%-and-%}for clean output - Cache repeated object access in variables
- Keep expensive operations outside loops
- Use
| jsonfor debugging objects and variables - 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...