Both Haml and Slim remove closing tags by using indentation, but they differ in how much syntax you see on the screen. Haml puts a % in front of every element and uses Ruby-style hash attributes. Slim removes the % and uses simpler, space-based attributes. This change affects whether you can quickly spot elements by symbols or prefer a cleaner look with fewer marks.
Haml started in 2006 as a way to use whitespace for structure and avoid writing closing tags. It marks elements with %, uses short forms for common attributes, and relies on indentation to show nesting. Slim came later in 2010 and took the idea further by removing the % and giving you more flexible ways to write attributes.
As a Rails user, you choose between them based on how much syntax you want to see while writing views. Haml keeps the element markers visible and uses Ruby-style attributes, which makes it easy to tell HTML apart from Ruby. Slim hides extra symbols and lets you write attributes in different ways, focusing on speed and a cleaner layout. Your choice affects how fast you work, how easy your code is to read, and whether you like clear visual markers or minimal punctuation.
What is Haml?
Haml turns indentation based markup into HTML by using whitespace to show structure. You start element names with %, apply classes and IDs with . and #, and you never write closing tags. Haml reads your indentation to build a clean HTML structure, so the spacing on the page shows the parent and child relationships.
The language uses clear symbols for different actions. Elements start with % such as %article or %section. Ruby output uses =. Ruby code that runs without output uses -. Attributes sit inside curly braces that follow Ruby hash style. These symbols help you quickly spot elements, Ruby output, and control code.
Haml requires accurate indentation to work correctly. Each level of indentation shows how an element fits under the previous one. If you reduce indentation, you close the previous parent element. Most people use two spaces per level. These strict whitespace rules prevent broken HTML but require careful formatting.
Haml is available as a gem and works directly with Rails. You install haml, create .html.haml files, and Rails handles everything automatically. The gem compiles your Haml into Ruby methods when the app starts, which gives you performance similar to ERB. You do not need any extra build steps or special tools.
Templates blend element notation with Ruby evaluation:
-# Haml - marked elements with Ruby
.dashboard
%header.page-header
%h1= @dashboard_title
- if @widgets.present?
.widget-grid
- @widgets.each do |widget|
%article.widget{id: "widget-#{widget.id}"}
%h3.widget-title= widget.name
.widget-content= widget.render_content
The .dashboard generates a div with that class. The %header.page-header creates a header element with a class. The = evaluates and outputs Ruby. The - if runs conditional logic. The %article.widget builds an article with a class and dynamic ID. Each % clearly indicates an HTML element. The syntax makes element boundaries explicit.
What is Slim?
Slim converts very simple markup into HTML by guessing the element type from context and using indentation to show structure. You write element names directly with no prefixes, use shortcuts for classes and IDs, and rely on indentation to nest elements. Because Slim removes extra symbols, your templates look cleaner and have less visual noise than Haml. This makes the content stand out more than the syntax.
Slim removes most of the punctuation that Haml uses. Elements look bare, for example article or section with no percent sign. Ruby output still uses = but looks cleaner. Logic still uses -. Attributes can be written with spaces or parentheses instead of hash braces. With fewer symbols on the screen, you type faster and your templates feel more compact.
Slim treats whitespace even more seriously than Haml. Indentation still controls nesting, but Slim also pays attention to spacing on each line. A pipe | keeps leading spaces in your text. A single quote ' outputs literal text without running Ruby. Space based attributes mean that even extra spaces can change meaning. This gives you flexibility but requires you to learn Slim spacing rules.
Slim installs as a gem and works smoothly with Rails. You add slim-rails, create .html.slim files, and Rails takes care of compiling them. The gem turns Slim code into Ruby, and Rails compiles that Ruby into methods. Slim runs at about the same speed as Haml and ERB. Any speed differences come from how complex your template is, not the template engine.
Templates reduce syntax to essentials:
/ Slim - unmarked elements
.dashboard
header.page-header
h1 = @dashboard_title
- if @widgets.present?
.widget-grid
- @widgets.each do |widget|
article.widget id="widget-#{widget.id}"
h3.widget-title = widget.name
.widget-content = widget.render_content
The .dashboard creates a div matching Haml. The header.page-header appears without markup. The = outputs Ruby values. The - if executes conditionally. The article.widget builds an article without prefix. Removing % saves characters and reduces clutter. Structure stays identical to Haml but looks sparser.
Haml vs Slim: quick comparison
| Aspect | Haml | Slim |
|---|---|---|
| Element markers | % required for elements |
Elements appear unmarked |
| Attribute format | Hash braces {key: value} |
Spaces or parentheses |
| Text handling | Plain text or = for Ruby |
Pipe ` |
| Raw output | != for unescaped HTML |
== for unescaped HTML |
| Ruby execution | - for code blocks |
- for code blocks |
| Comment types | -# silent, / HTML output |
/ HTML, /! multiline HTML |
| Ruby interpolation | #{} in strings |
#{} in strings |
| Doctype syntax | !!! or !!! 5 |
doctype html |
| Whitespace handling | > < modifiers |
Text markers control spacing |
| Typing volume | Moderate characters | Minimal characters |
Element visibility
The marker difference stood out when I built a product listing page. Haml's prefixes made elements immediately recognizable:
-# Haml - prefixed elements
.product-catalog
%aside.filters
%form.filter-form{method: 'get'}
.filter-group
%label{for: 'category'} Category
%select#category{name: 'category'}
- @categories.each do |cat|
%option{value: cat.id, selected: (cat.id == @selected_category)}= cat.name
%main.product-grid
- @products.each do |product|
%article.product-card
.product-image
%img{src: product.image_url, alt: product.name}
.product-details
%h2.product-name= product.name
%p.product-price= number_to_currency(product.price)
Every HTML element had a % marker. Scanning the file, I instantly separated elements from Ruby logic. The %aside, %form, %select, %option prefixes made the DOM structure jump out. When debugging layout problems, I traced the element hierarchy by following prefixes. The consistency helped during code reviews—team members read templates without confusion.
Slim removed prefixes throughout:
/ Slim - bare elements
.product-catalog
aside.filters
form.filter-form method='get'
.filter-group
label for='category' Category
select#category name='category'
- @categories.each do |cat|
option value=cat.id selected=(cat.id == @selected_category) = cat.name
main.product-grid
- @products.each do |product|
article.product-card
.product-image
img src=product.image_url alt=product.name
.product-details
h2.product-name = product.name
p.product-price = number_to_currency(product.price)
Elements appeared as bare words—aside, form, select, option without markers. The template looked cleaner and required less typing. But distinguishing HTML from Ruby took more cognitive effort initially. The line label for='category' Category could be mistaken for a method call until recognizing it as an element. After a few days writing Slim exclusively, my brain adapted and parsed it naturally.
Attribute approaches
That product catalog highlighted attribute differences. Haml applies Ruby hash conventions:
-# Haml - hash attributes
%form.search-form{method: 'post',
action: search_path,
data: {controller: 'search',
search_target: 'form',
action: 'submit->search#execute'}}
%input.search-input{type: 'text',
name: 'query',
placeholder: 'Search products...',
required: true,
autofocus: true}
-# Can also use HTML-style parentheses
%button(type='submit' class='btn btn-primary') Search
Attributes stayed inside curly braces as Ruby hashes. Nested hashes for data attributes matched Rails conventions—data: {controller: 'value'} compiled to data-controller="value". Boolean attributes rendered without values when true. The hash syntax felt natural for Ruby developers but needed careful comma placement. Multi-line attributes required consistent indentation.
Slim provides three distinct approaches:
/ Slim - multiple attribute styles
/ Space-separated (most common)
form.search-form method='post' action=search_path data-controller='search' data-search-target='form' data-action='submit->search#execute'
/ Parentheses for grouping
input.search-input(type='text' name='query' placeholder='Search products...' required=true autofocus=true)
/ Ruby hash style
button{type: 'submit', class: 'btn btn-primary'} Search
/ Mixing styles
form method='post' {data: {controller: 'search', target: 'form'}}
Space-separated attributes looked cleanest for straightforward cases. Parentheses grouped attributes without commas. Hash style matched Haml. I could mix approaches on the same element. The flexibility was powerful but introduced inconsistency—different developers preferred different styles. We standardized on spaces for static attributes and hashes for data attributes only.
Dynamic content in attributes
The attribute flexibility influenced dynamic value handling. Haml interpolates within hash values:
-# Haml - dynamic attributes
%div{class: "product-card status-#{@product.status} #{@featured ? 'featured' : ''}"}
%a.product-link{href: product_path(@product),
class: ['link', @product.availability, (@product.new? ? 'new-arrival' : nil)]}
%section{data: {product_id: @product.id,
product_name: @product.name,
variants: @product.variants.to_json,
available: @product.available?}}
String interpolation with #{} worked inside values. Class arrays merged and filtered nil values automatically. Data attributes converted values to strings. The hash syntax became verbose for many dynamic values—lots of braces and quote characters. Complex ternary operators inline hurt readability.
Slim handles dynamic attributes with less punctuation:
/ Slim - dynamic attributes simplified
div class="product-card status-#{@product.status} #{@featured ? 'featured' : ''}"
a.product-link href=product_path(@product) class=['link', @product.availability, (@product.new? ? 'new-arrival' : nil)]
section data-product-id=@product.id data-product-name=@product.name data-variants=@product.variants.to_json data-available=@product.available?
/ Hash spreading with splat
div *{data: {product_id: @product.id, product_name: @product.name}}
Using = after attribute names evaluated Ruby directly. No hash braces needed for simple cases. Arrays functioned like Haml. The splat * operator spread hash contents into attributes. Less punctuation made dynamic content more readable, though mixing quoted strings and Ruby expressions on one line required attention to spacing.
Text content patterns
The attribute syntax connected to text handling approaches. Haml allows text on the same line or indented below:
-# Haml - text placement
%h1 Welcome to Our Store
%p
Browse our extensive collection
of high-quality products at
unbeatable prices
%div
Shop now for
= link_to 'best deals', deals_path
on seasonal items
-# Preserve whitespace with tilde
%pre.code-block
~ @formatted_code
Text directly after elements worked naturally. Multi-line text indented below the element. Mixing static text with Ruby on multiple lines required positioning = carefully. The tilde ~ preserved whitespace for code blocks or pre-formatted content. The behavior felt intuitive after learning the rules.
Slim needs explicit markers for multi-line text:
/ Slim - text with markers
h1 Welcome to Our Store
p
| Browse our extensive collection
of high-quality products at
unbeatable prices
div
| Shop now for
= link_to 'best deals', deals_path
| on seasonal items
/ Literal text with single quote
blockquote
' "Quality is never an accident"
- Unknown
The pipe | preserved text with its leading spaces. Each text line needed a pipe marker. Without pipes, Slim interpreted lines as elements. The quote ' treated content as literal without Ruby evaluation. The explicit markers controlled text precisely but added more characters than Haml's implicit text.
Control structures
Text handling influenced how conditionals and loops appeared. Haml uses Ruby blocks with indentation:
-# Haml - control flow
- if @products.any?
.product-list
- @products.each_with_index do |product, index|
.product-item{class: ('even' if index.even?)}
%h3= product.name
- if product.on_sale?
.sale-badge Sale
- elsif product.new_arrival?
.new-badge New
- else
.regular-badge
- else
.empty-state
%p No products found
The - if started conditionals. Indented content lived inside the condition. The - elsif and - else provided alternatives. No closing keywords—dedenting terminated blocks. The - each_with_index iterated with position tracking. Standard Ruby block syntax with structure inferred from indentation.
Slim uses matching control syntax:
/ Slim - control flow
- if @products.any?
.product-list
- @products.each_with_index do |product, index|
.product-item class=('even' if index.even?)
h3 = product.name
- if product.on_sale?
.sale-badge Sale
- elsif product.new_arrival?
.new-badge New
- else
.regular-badge
- else
.empty-state
p No products found
The structure matched Haml exactly. The - ran Ruby logic. Indentation defined scope. The only difference was missing % on elements inside blocks. Converting control flow between languages meant mechanical element prefix changes. Logic stayed untouched.
Inline Ruby expressions
Control structures worked identically for inline expressions too. Haml interpolates Ruby everywhere:
-# Haml - inline Ruby
%p Hello, #{@user.name}! You've earned #{@user.points} points.
%div{class: "badge-#{@user.level}"}
%span.notification-count
= pluralize(@notifications.count, 'notification')
- summary = "Member since #{@user.created_at.year}, #{@user.orders.count} orders placed"
%p.member-info= summary
The #{} evaluated Ruby within text and attributes. Standard Ruby string interpolation applied universally. Complex expressions sometimes became hard to parse inline—extracting to variables improved clarity.
Slim uses identical interpolation:
/ Slim - inline Ruby
p Hello, #{@user.name}! You've earned #{@user.points} points.
div class="badge-#{@user.level}"
span.notification-count
= pluralize(@notifications.count, 'notification')
- summary = "Member since #{@user.created_at.year}, #{@user.orders.count} orders placed"
p.member-info = summary
The #{} worked exactly like Haml. Interpolation in text required pipe markers when on separate lines. Attribute interpolation appeared identical. Variable assignment matched perfectly. Switching between languages needed no adjustment for Ruby expressions.
HTML escaping
Interpolation behavior tied to output escaping. Haml escapes by default:
-# Haml - auto-escaping
%p= @product.description
-# Renders: <p><strong>Bold</strong></p>
-# Raw HTML with !=
.product-content
!= @product.html_description
-# Interpolation also escapes
%p Description: #{@product.description}
The = HTML-escaped all output. The != rendered raw unescaped HTML. Interpolation with #{} also escaped. Safe defaults prevented XSS attacks. I used != sparingly for trusted rich text from editors.
Slim escapes identically with different raw syntax:
/ Slim - auto-escaping
p = @product.description
/ Renders: <p><strong>Bold</strong></p>
/ Raw HTML with ==
.product-content
== @product.html_description
/ Interpolation escapes
p Description: #{@product.description}
The = escaped like Haml. The == (double equals) rendered raw HTML—different from Haml's !=. Interpolation escaped automatically. The different raw syntax initially tripped me up—muscle memory from Haml led to errors until I adjusted.
Embedded content filters
Escaping rules applied to content filters. Haml provides language-specific filters:
-# Haml - content filters
:javascript
window.ProductData = {
id: #{@product.id},
name: '#{j @product.name}',
price: #{@product.price}
};
:css
.product-#{@product.id} {
border-color: #{@product.brand_color};
}
:markdown
## Product Features
* Feature 1
* Feature 2
Available in **#{@product.variants.count}** variants
:ruby
def calculate_discount(price, percent)
price * (1 - percent / 100.0)
end
Colons prefix filter names. Indented content gets processed by that filter. JavaScript and CSS filters wrap output in script and style tags. Markdown converts to HTML. Ruby executes code without output. Filters kept templates organized when embedding substantial non-HTML content.
Slim supports matching filters:
/ Slim - content filters
javascript:
window.ProductData = {
id: #{@product.id},
name: '#{j @product.name}',
price: #{@product.price}
};
css:
.product-#{@product.id} {
border-color: #{@product.brand_color};
}
markdown:
## Product Features
* Feature 1
* Feature 2
Available in **#{@product.variants.count}** variants
ruby:
def calculate_discount(price, percent)
price * (1 - percent / 100.0)
end
Filter syntax nearly matched—javascript: instead of :javascript. Processing worked identically. Interpolation functioned in filtered content. Capabilities aligned completely. Converting filters between formats meant moving the colon character.
Whitespace control
Filter similarity contrasted with whitespace approaches. Haml uses modifier symbols:
-# Haml - whitespace modifiers
%p>
%em Emphasized
text inline
-# Output: <p><em>Emphasized</em>text inline</p>
%pre
Code block
with indentation
preserved
-# Preserves internal spacing
%tag>
Combined modifiers
The > removed whitespace around tags. The < preserved inner whitespace. Combining both controlled spacing precisely. The symbols worked but required memorizing their meanings. I used them infrequently—mostly for inline elements or preformatted code.
Slim handles whitespace through text operators:
/ Slim - whitespace control
p
em Emphasized
| text inline
/ Output: <p><em>Emphasized</em> text inline</p>
pre
' Code block
with indentation
preserved
/ Preserves spacing with quote
/ Precise control with pipes
span
| Start
strong middle
| end
The pipe | preserved whitespace including leading spaces. The quote ' preserved multiline spacing. Explicit markers made whitespace handling visible but needed more characters. I found Slim's approach clearer—seeing pipes showed exactly where whitespace mattered. Haml's modifiers were shorter but less obvious when scanning.
Documentation styles
Whitespace tools connected to commenting approaches. Haml offers different comment types:
-# Haml - comments
-# Silent developer comment
-# Never appears in output
/ HTML comment visible in source
/ Shows up when viewing page source
/[if lt IE 9]
%script{src: 'html5shiv.js'}
-# Multi-line silent comments need
-# markers on each line
-# like this example
The -# created silent comments removed before rendering. The / created HTML comments in output. Conditional comments targeted specific browsers. Multi-line silent comments needed -# on every line. The distinction separated developer notes from debugging comments.
Slim uses similar approaches:
/ Slim - comments
/ HTML comment - appears in output
/ Visible in page source
/! Multi-line HTML comment
that spans several lines
without repeating markers
/[if lt IE 9]
script src='html5shiv.js'
-# Code comment (rarely used)
The / made HTML comments. The /! created multiline HTML comments without repeating markers. Conditional comments worked identically. Slim favored HTML comments over silent ones—the / was shorter. I commented more frequently in Slim due to simpler syntax. Haml's -# for silent comments felt more deliberate.
Partial rendering
Commenting patterns influenced partial structure. Haml renders partials with Rails helpers:
-# Haml - partials
.products-section
= render 'products/filters', categories: @categories
.products-grid
= render partial: 'products/card', collection: @products, locals: {show_actions: true}
-# _card.html.haml
.product-card
.card-image
%img{src: product.image_url, alt: product.name}
.card-body
%h3.card-title= product.name
%p.card-price= number_to_currency(product.price)
- if show_actions
.card-actions
= link_to 'View', product_path(product), class: 'btn'
The render method worked regardless of template format. Locals passed as hash arguments. Collection rendering iterated automatically. Partials used Haml syntax. Integration was seamless—partial language didn't affect invocation.
Slim renders identically:
/ Slim - partials
.products-section
= render 'products/filters', categories: @categories
.products-grid
= render partial: 'products/card', collection: @products, locals: {show_actions: true}
/ _card.html.slim
.product-card
.card-image
img src=product.image_url alt=product.name
.card-body
h3.card-title = product.name
p.card-price = number_to_currency(product.price)
- if show_actions
.card-actions
= link_to 'View', product_path(product), class: 'btn'
Render calls matched Haml precisely. Locals passed through identically. Only the partial's internal syntax differed. I mixed Haml and Slim partials freely—Rails detected extensions and used appropriate renderers. Interoperability enabled gradual migration without breaking functionality.
Helper integration
Partial compatibility extended to helper methods. Haml invokes helpers like Ruby methods:
-# Haml - helper methods
= form_with model: @product do |f|
.field
= f.label :name
= f.text_field :name, class: 'form-input'
.field
= f.label :description
= f.text_area :description, rows: 4
.actions
= f.submit 'Save Product', class: 'btn-primary'
= link_to 'Back', products_path, class: 'back-link', data: {turbo_frame: '_top'}
= content_tag :section, class: 'alert' do
%p= flash[:alert]
The = called helpers and output results. Form builders nested naturally. Block syntax worked for helpers accepting blocks. Attribute hashes passed as arguments. Helpers behaved like standard Ruby methods with output.
Slim invokes helpers identically:
/ Slim - helper methods
= form_with model: @product do |f|
.field
= f.label :name
= f.text_field :name, class: 'form-input'
.field
= f.label :description
= f.text_area :description, rows: 4
.actions
= f.submit 'Save Product', class: 'btn-primary'
= link_to 'Back', products_path, class: 'back-link', data: {turbo_frame: '_top'}
= content_tag :section, class: 'alert' do
p = flash[:alert]
Helper calls matched Haml completely. Form methods worked identically. Block helpers used same syntax. The only difference was element syntax inside blocks—no % prefix. Helper compatibility meant migration involved syntax changes, not logic rewrites.
Runtime characteristics
Helper compatibility revealed similar performance. Haml compiles to Ruby at startup:
# Haml compilation
def _app_views_products_show_html_haml___12345(local_assigns, output_buffer)
product = local_assigns[:product]
output_buffer.safe_append("<div class='product-details'>\n")
output_buffer.safe_append(" <h1>")
output_buffer.safe_append(::Haml::Helpers.html_escape(product.name))
output_buffer.safe_append("</h1>\n</div>\n")
output_buffer
end
Rails compiled Haml once during boot in production. Subsequent renders called compiled methods. No parsing overhead after compilation. Performance matched hand-written string concatenation.
Slim compiles similarly:
# Slim compilation
def _app_views_products_show_html_slim___67890(local_assigns, output_buffer)
product = local_assigns[:product]
output_buffer.safe_append("<div class=\"product-details\">\n")
output_buffer.safe_append(" <h1>")
output_buffer.safe_append(::Temple::Utils.escape_html(product.name))
output_buffer.safe_append("</h1>\n</div>\n")
output_buffer
end
Compiled output looked nearly identical. Slim parsed syntax, generated Ruby, then Rails compiled to methods. The parsing step added negligible overhead. Benchmarks showed Slim marginally faster than Haml—roughly 5-8% in complex templates. Performance difference came from Slim's optimized parser, not fundamental architecture.
Development tools
Performance parity made tooling crucial. Haml enjoys mature tool support:
# Haml - established tooling
- Editors: Plugins for VSCode, Sublime, Vim, RubyMine, Emacs
- Linting: haml-lint with extensive rules and auto-fixing
- Conversion: html2haml for ERB/HTML conversion
- Formatting: haml-lint handles formatting
- Community: Large user base, abundant tutorials
- Support: Active Stack Overflow presence
- History: Maintained since 2006
Haml plugins existed for every editor our team used. The haml-lint gem caught errors and enforced style. Converting from ERB used html2haml. Stack Overflow had answers for most issues. The mature ecosystem meant fewer surprises.
Slim has active but smaller tooling:
# Slim - growing tools
- Editors: Plugins available, quality varies by editor
- Linting: slim-lint with fewer configuration options
- Conversion: html2slim for ERB/HTML conversion
- Formatting: Limited auto-formatting capabilities
- Community: Smaller active community
- Support: Less Stack Overflow coverage
- History: Maintained since 2010
Slim plugins worked in major editors but varied in quality. The slim-lint gem caught errors with fewer customization options. The html2slim tool converted templates. Finding solutions to unusual problems required more digging. The smaller community meant occasionally pioneering solutions.
Format conversion
Tooling differences affected migration. Converting Haml to Slim:
# Install converter
gem install haml2slim
# Single file conversion
haml2slim app/views/products/show.html.haml app/views/products/show.html.slim
# Bulk directory conversion
haml2slim app/views app/views
The haml2slim tool automated conversion reasonably well. Simple templates converted cleanly. Complex templates with unusual constructs needed manual fixes. I converted high-churn views first, leaving stable templates in Haml. Gradual migration avoided disrupting development.
Converting Slim back to Haml:
# Install reverse converter
gem install slim2haml
# Convert files back
slim2haml app/views/products/show.html.slim app/views/products/show.html.haml
The slim2haml tool existed but saw less use since migrations typically went toward Slim. Conversion handled straightforward syntax differences but struggled with filters, complex Ruby, and whitespace edge cases.
Final thoughts
This article compares Haml and Slim to help you choose the template language that matches how you like to work.
If you want clear visual structure without losing simplicity, Haml may be the better fit. The element markers help you scan a file quickly, the Ruby style attributes feel familiar, and the strong tooling makes editing easier. Haml works well if you are coming from ERB, if you care more about readability than saving a few characters, or if you like having lots of community guides and resources.
If you want to type less and keep your templates as small as possible, Slim gives you a lighter option. The reduced syntax makes your files shorter, the flexible attribute formats let you choose what feels natural, and the clean layout removes most visual clutter. Slim works best if you are comfortable with indentation based languages, want to reduce boilerplate, or simply prefer a minimalist style.