ERB vs Haml: Syntax, Structure, and How They Shape Your Rails Views
Template syntax determines how you write views and how quickly you spot nesting errors or missing tags. ERB embeds Ruby directly into HTML with minimal transformation, while Haml uses indentation and shorthand to eliminate closing tags entirely. This difference affects code readability and how you reason about markup structure, not just character count.
ERB (Embedded Ruby) shipped with Rails in 2004 as the default templating language. The syntax wraps Ruby code in <% %> tags while leaving HTML unchanged, making it familiar to developers who already knew HTML. Haml appeared in 2006 when Hampton Catlin created a markup language that used significant whitespace and eliminated closing tags, trading HTML familiarity for enforced structure and reduced repetition.
Modern Rails applications choose based on team experience and template complexity. ERB keeps standard HTML syntax with Ruby interpolation, letting you copy-paste from design tools or HTML prototypes. Haml forces consistent indentation and prevents tag mismatch errors through its syntax rules.
What is ERB?
ERB processes templates by executing Ruby code between delimiter tags while passing through everything else as-is. You write normal HTML and inject dynamic content using <%= %> for output or <% %> for logic. The preprocessor evaluates Ruby expressions and replaces tags with their results. Views look like HTML with programming constructs sprinkled throughout.
The syntax uses two tag types that control rendering behavior. Output tags <%= %> evaluate Ruby and insert the result into the HTML. Execution tags <% %> run Ruby code without outputting anything, useful for loops and conditionals. You write HTML exactly as it would appear in a static file, then add Ruby where you need dynamic behavior.
Templates maintain standard HTML structure with explicit opening and closing tags. When you write a <div>, you write the matching </div> manually. The lack of automatic tag closing means you can introduce mismatches—forgetting a closing tag creates invalid HTML that browsers might render incorrectly. ERB doesn't enforce structure; it trusts you to write valid markup.
Rails includes ERB by default without additional gems. You create .html.erb files in your views directory and Rails renders them automatically. No configuration needed, no syntax to learn beyond the tag delimiters. Designers familiar with HTML can read and modify ERB templates without learning a new language.
Templates mix HTML and Ruby naturally:
<!-- ERB - standard HTML with Ruby interpolation -->
<div class="user-profile">
<h1><%= @user.name %></h1>
<% if @user.admin? %>
<span class="badge">Administrator</span>
<% end %>
<ul class="posts">
<% @user.posts.each do |post| %>
<li>
<h3><%= post.title %></h3>
<p><%= post.excerpt %></p>
</li>
<% end %>
</ul>
</div>
The <%= @user.name %> tag outputs the user's name. The <% if %> block conditionally renders the admin badge. The <% each %> loop generates list items for posts. Standard HTML structure with Ruby logic inserted where needed. You see exactly what HTML gets generated by reading the template.
What is Haml?
% and omit closing tags entirely—indentation determines nesting. Ruby code appears without delimiters when outputting values or wrapped in - for execution-only statements. The preprocessor transforms Haml's terse syntax into standard HTML.The syntax relies on significant whitespace to establish document structure. Indenting a line makes it a child of the previous line. Dedenting closes all open parent elements at that level. This makes nesting explicit and impossible to mismatch—indentation errors become syntax errors rather than rendering bugs. You can't accidentally forget a closing tag because closing tags don't exist.
Templates use shorthand for common patterns. The # symbol creates an ID attribute, . creates a class. Element names default to div when omitted, so .container becomes <div class="container">. Ruby expressions after = get evaluated and output. The syntax reduces repetition at the cost of learning Haml-specific conventions.
Rails applications need the haml gem to use Haml templates. You add it to your Gemfile, run bundle install, and create .html.haml files. The gem hooks into Rails' rendering pipeline and converts Haml to HTML during view rendering. Existing ERB templates continue working—you can mix both formats in the same application.
Templates express structure through indentation:
-# Haml - indentation-based markup
.user-profile
%h1= @user.name
- if @user.admin?
%span.badge Administrator
%ul.posts
- @user.posts.each do |post|
%li
%h3= post.title
%p= post.excerpt
The .user-profile line creates a div with that class. The %h1= outputs the user name in an h1 tag. The - if executes Ruby without output. The %ul.posts creates a list with a class. Each indentation level nests elements inside their parents. No closing tags needed—dedenting closes elements automatically.
ERB vs Haml: quick comparison
| Aspect | ERB | Haml |
|---|---|---|
| Syntax basis | HTML with Ruby tags | Indentation-based |
| Closing tags | Explicit </tag> required |
Automatic via dedenting |
| Learning curve | Minimal for HTML developers | Steeper, new syntax |
| HTML familiarity | Identical to standard HTML | Converted from shorthand |
| Whitespace significance | Cosmetic only | Defines structure |
| Tag matching errors | Possible, creates invalid HTML | Impossible by design |
| ID/class shorthand | No shorthand | #id and .class |
| Default element | Must specify all tags | Defaults to div |
| Ruby delimiters | <% %> and <%= %> |
- and = |
| Copy-paste HTML | Works directly | Requires conversion |
Template readability
The syntax difference became obvious when I refactored a complex navigation component. ERB preserved standard HTML structure:
<!-- ERB - nested navigation -->
<nav class="main-navigation">
<div class="nav-container">
<ul class="nav-items">
<% @navigation_items.each do |item| %>
<li class="nav-item <%= 'active' if current_page?(item.path) %>">
<a href="<%= item.path %>">
<%= item.name %>
</a>
<% if item.children.any? %>
<ul class="submenu">
<% item.children.each do |child| %>
<li class="submenu-item">
<a href="<%= child.path %>">
<%= child.name %>
</a>
</li>
<% end %>
</ul>
<% end %>
</li>
<% end %>
</ul>
</div>
</nav>
I could see every opening and closing tag explicitly. The structure matched what I'd write in a static HTML file. Designers working on the project read the template without confusion—it looked like HTML they already knew. When debugging layout issues, I counted tags to verify nesting depth. The verbosity made structure obvious but required careful attention to closing tags.
Haml compressed the same navigation significantly:
-# Haml - nested navigation
%nav.main-navigation
.nav-container
%ul.nav-items
- @navigation_items.each do |item|
%li.nav-item{class: ('active' if current_page?(item.path))}
%a{href: item.path}= item.name
- if item.children.any?
%ul.submenu
- item.children.each do |child|
%li.submenu-item
%a{href: child.path}= child.name
The indentation made nesting immediately apparent. Each level represented a parent-child relationship visually. I could scan the template and understand structure at a glance. But designers unfamiliar with Haml found it alien. They couldn't copy markup from Figma or HTML prototypes directly—everything needed conversion to Haml syntax first.
Handling dynamic attributes
That navigation code revealed how each system handles dynamic attributes. ERB uses standard HTML attribute syntax:
<!-- ERB - dynamic attributes -->
<div class="card <%= @featured ? 'featured' : '' %> <%= @urgent ? 'urgent' : '' %>">
<input
type="text"
name="user[email]"
value="<%= @user.email %>"
<%= 'required' if @user.new_record? %>
<%= 'disabled' if @user.locked? %>
/>
</div>
Dynamic classes got concatenated as strings. Conditional attributes needed ternary operators or conditional rendering. The syntax worked but became messy with multiple dynamic values. I built helper methods to clean up complex attribute logic, moving Ruby code out of templates into the helpers file.
Haml provides hash syntax for attributes:
-# Haml - dynamic attributes
.card{class: [('featured' if @featured), ('urgent' if @urgent)]}
%input{type: 'text',
name: 'user[email]',
value: @user.email,
required: @user.new_record?,
disabled: @user.locked?}
The hash syntax felt more Ruby-native. Conditional attributes took booleans directly—Haml rendered them only when true. Arrays in the class attribute merged automatically, filtering out nil values. The approach kept attribute logic cleaner without helper methods, though the hash syntax added noise compared to standard HTML attributes.
Partial rendering
The attribute handling influenced partial design patterns. ERB partials receive local variables through the locals hash:
<!-- ERB - rendering partials -->
<div class="comments-section">
<%= render partial: 'comments/comment',
collection: @post.comments,
locals: { show_reply: true, depth: 0 } %>
</div>
<!-- _comment.html.erb partial -->
<div class="comment" style="margin-left: <%= depth * 20 %>px">
<p class="author"><%= comment.author.name %></p>
<p class="body"><%= comment.body %></p>
<% if show_reply %>
<button class="reply-button">Reply</button>
<% end %>
<% if comment.replies.any? %>
<div class="replies">
<%= render partial: 'comments/comment',
collection: comment.replies,
locals: { show_reply: true, depth: depth + 1 } %>
</div>
<% end %>
</div>
The partial rendered recursively for threaded comments. I passed depth to calculate indentation and show_reply to control button visibility. The explicit locals made dependencies clear but required typing locals hash repeatedly. For deeply nested components, the partial calls became verbose.
Haml partials work identically but with terser syntax:
-# Haml - rendering partials
.comments-section
= render partial: 'comments/comment',
collection: @post.comments,
locals: {show_reply: true, depth: 0}
-# _comment.html.haml partial
.comment{style: "margin-left: #{depth * 20}px"}
%p.author= comment.author.name
%p.body= comment.body
- if show_reply
%button.reply-button Reply
- if comment.replies.any?
.replies
= render partial: 'comments/comment',
collection: comment.replies,
locals: {show_reply: true, depth: depth + 1}
The partial call looked nearly identical—Rails' rendering API works the same regardless of template language. Inside the partial, Haml's concise syntax reduced the line count without changing the logic. The interpolation #{depth * 20}px felt more natural than ERB's <%= %> tags within attributes.
Whitespace control
Those nested partials exposed whitespace handling differences. ERB outputs whitespace literally:
<!-- ERB - whitespace appears in output -->
<ul>
<% @items.each do |item| %>
<li><%= item.name %></li>
<% end %>
</ul>
<!-- Renders as: -->
<ul>
<li>First</li>
<li>Second</li>
</ul>
The blank lines from Ruby tags appeared in the HTML output. For most layouts this didn't matter—browsers collapse whitespace. But for inline elements or <pre> tags, the extra whitespace caused layout issues. I used -%> to suppress trailing newlines or wrote compact templates without extra spacing.
Haml controls whitespace through its indentation rules:
-# Haml - whitespace controlled by syntax
%ul
- @items.each do |item|
%li= item.name
-# Renders as:
<ul>
<li>First</li>
<li>Second</li>
</ul>
Haml generated clean HTML without spurious newlines. The output matched the visual structure of the source template. For inline elements, I used > to remove whitespace around tags or < to preserve inner whitespace. The explicit control prevented whitespace bugs without thinking about it.
Escaping and raw output
The whitespace control connected to output escaping behavior. ERB escapes HTML by default with <%= %>:
<!-- ERB - automatic HTML escaping -->
<p><%= @user.bio %></p>
<!-- If bio contains "<script>alert('xss')</script>" -->
<!-- Renders: <p><script>alert('xss')</script></p> -->
<!-- Raw HTML with raw or html_safe -->
<div class="content">
<%= raw @post.html_content %>
</div>
<!-- Or use html_safe on the string -->
<div class="content">
<%= @post.html_content.html_safe %>
</div>
Rails escaped user content automatically, preventing XSS attacks. I used raw helper when I needed to render trusted HTML like rich text editor content. The explicit raw call made it obvious where unescaped content appeared, though I sometimes forgot it when rendering markdown or HTML fields.
Haml escapes output identically but uses different syntax for raw output:
-# Haml - automatic HTML escaping
%p= @user.bio
-# Renders: <p><script>alert('xss')</script></p>
-# Raw HTML with != operator
.content
!= @post.html_content
-# Or use find_and_preserve for whitespace-sensitive content
%pre
= find_and_preserve(@code_snippet)
The != operator rendered unescaped HTML. More concise than ERB's raw helper but easier to miss during code review. I established linting rules to flag != usage and require comments explaining why content was trusted. The shorter syntax made both escaping and raw output less prominent in the code.
Commenting
The escaping syntax differences extended to comments. ERB uses HTML comments and Ruby comments:
<!-- ERB - HTML and Ruby comments -->
<!-- HTML comment - appears in rendered output -->
<div class="user-card">
<!-- This shows in view source -->
<h2><%= @user.name %></h2>
</div>
<%# Ruby comment - removed before rendering %>
<div class="stats">
<%# This won't appear in HTML %>
<span><%= @user.post_count %></span>
</div>
HTML comments <!-- --> appeared in the rendered page source. Ruby comments <%# %> got stripped during preprocessing. I used HTML comments for debugging markup and Ruby comments for developer notes. The distinction was useful but required remembering two syntaxes.
Haml provides comment types for different visibility:
-# Haml - multiple comment styles
/ HTML comment - appears in rendered output
.user-card
/ This shows in view source
%h2= @user.name
-# Haml comment - never rendered
.stats
-# This won't appear in HTML
%span= @user.post_count
/[if IE]
%p Your browser is ancient
The / prefix created HTML comments. The -# prefix created silent comments removed before rendering. Haml also supported conditional comments for IE-specific markup. Having distinct syntaxes for each comment type made intentions clear, though I found myself using -# by default and rarely needing HTML comments.
Multi-line expressions
The commenting patterns revealed how each system handles multi-line code. ERB lets Ruby expressions span multiple lines naturally:
<!-- ERB - multi-line expressions -->
<%= link_to user_path(@user),
class: "user-link #{@user.status}",
data: {
turbo: true,
controller: 'tooltip',
tooltip_text: "View #{@user.name}'s profile"
} do %>
<span class="name"><%= @user.name %></span>
<span class="role"><%= @user.role %></span>
<% end %>
The opening <%= %> tag enclosed the entire link_to call including its block. Breaking method arguments across lines kept them readable. The block content sat between the opening tag and <% end %>. Natural Ruby syntax without special multi-line handling.
Haml requires explicit continuation for multi-line expressions:
-# Haml - multi-line with pipe character
= link_to user_path(@user), |
class: "user-link #{@user.status}", |
data: { |
turbo: true, |
controller: 'tooltip', |
tooltip_text: "View #{@user.name}'s profile" |
} do |
%span.name= @user.name
%span.role= @user.role
The pipe | character continued expressions across lines. I needed to add it at the end of each line that continued. Forgetting a pipe caused syntax errors. The explicit continuation made line breaks visible but added visual noise. For complex method calls with many arguments, the pipes cluttered the code.
Conditionals and loops
The multi-line handling affected how I wrote conditionals. ERB uses standard Ruby control structures:
<!-- ERB - Ruby conditionals and loops -->
<% if @user.premium? %>
<div class="premium-badge">Premium Member</div>
<% elsif @user.trial? %>
<div class="trial-badge">Trial Period</div>
<% else %>
<div class="free-badge">Free Account</div>
<% end %>
<% @notifications.each_with_index do |notification, index| %>
<div class="notification <%= 'unread' if notification.unread? %>">
<span class="index"><%= index + 1 %></span>
<p><%= notification.message %></p>
</div>
<% end %>
The Ruby blocks appeared between <% %> tags. I wrote if, elsif, else, and end explicitly. Loops used standard Ruby iterators with blocks. The syntax matched Ruby code exactly, making control flow obvious to anyone who knew Ruby.
Haml uses the same Ruby keywords but without explicit end tags:
-# Haml - Ruby conditionals and loops
- if @user.premium?
.premium-badge Premium Member
- elsif @user.trial?
.trial-badge Trial Period
- else
.free-badge Free Account
- @notifications.each_with_index do |notification, index|
.notification{class: ('unread' if notification.unread?)}
%span.index= index + 1
%p= notification.message
Indentation determined where blocks ended instead of end keywords. The - prefix executed Ruby without output. Control structures looked cleaner without closing tags, but indentation became critical—incorrect indentation changed which elements lived inside conditionals. I caught these errors only when views rendered incorrectly.
Filters for embedded content
The control flow patterns connected to how each system embeds other content types. ERB requires explicit HTML for embedded content:
<!-- ERB - embedded JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const userId = <%= @user.id %>;
const userName = '<%= j @user.name %>';
console.log(`User ${userName} loaded`);
});
</script>
<!-- ERB - embedded CSS -->
<style>
.user-<%= @user.id %> {
background-color: <%= @user.theme_color %>;
}
</style>
I wrote <script> and <style> tags manually and interpolated Ruby values inside them. The j helper escaped JavaScript strings. The approach worked but mixed languages without syntax highlighting support in most editors. Interpolating into JavaScript felt error-prone—missing escaping caused syntax errors or security issues.
Haml provides filters for embedding different content types:
-# Haml - filtered content
:javascript
document.addEventListener('DOMContentLoaded', function() {
const userId = #{@user.id};
const userName = '#{j @user.name}';
console.log(`User ${userName} loaded`);
});
:css
.user-#{@user.id} {
background-color: #{@user.theme_color};
}
:markdown
# User Profile
Welcome to **#{@user.name}**'s profile page.
The :javascript and :css filters wrapped content in appropriate tags automatically. The :markdown filter processed markdown into HTML. Interpolation used #{} inside filtered content. Some editors provided syntax highlighting within filters. The filter approach kept templates cleaner when embedding significant chunks of non-HTML content.
Performance characteristics
The filter features highlighted performance differences. ERB compiles templates to Ruby methods once:
# ERB - compiled to Ruby method
def _app_views_users_show_html_erb___12345(local_assigns, output_buffer)
@user = local_assigns[:user]
output_buffer.safe_append('<div class="user-profile">')
output_buffer.safe_append('<h1>')
output_buffer.safe_append(ERB::Util.html_escape(@user.name))
output_buffer.safe_append('</h1></div>')
output_buffer
end
Rails compiled ERB templates to Ruby methods at startup in production. Subsequent renders called the compiled method directly—no template parsing overhead. The compilation happened once per deploy. Template rendering performance matched hand-written Ruby string concatenation.
Haml compiles through an additional layer:
# Haml - compiled to Ruby, then executed
# Haml source:
# .user-profile
# %h1= @user.name
# Compiles to Ruby:
_hamlout = Haml::Buffer.new(...)
_hamlout.push_text("<div class='user-profile'>\n", 0)
_hamlout.push_text("<h1>", 2)
_hamlout.push_script(@user.name, false)
_hamlout.push_text("</h1>\n</div>", 0)
_hamlout.buffer
Haml parsed its syntax, generated Ruby code, then Rails compiled that code. The extra compilation step added minimal overhead—happened once at startup like ERB. Runtime performance differed by single-digit percentages in my benchmarks. The difference became noticeable only when rendering thousands of complex templates per second, which never happened in my applications.
Mixing template languages
The performance parity meant I could mix both in one application. ERB and Haml coexist naturally:
# app/views/users/show.html.erb
<div class="user-page">
<%= render 'users/profile' %>
<!-- Renders _profile.html.haml if it exists -->
<%= render 'users/stats' %>
<!-- Renders _stats.html.erb -->
</div>
Rails picked the template format based on file extension. I started projects with ERB and converted views to Haml gradually. Partials in either format rendered through the same render call. The interop made migration painless—no big-bang conversion required.
Converting between formats required tools or manual work:
# Convert ERB to Haml
gem install html2haml
html2haml app/views/users/show.html.erb app/views/users/show.html.haml
# Or use rake task
bundle exec rake haml:convert
The html2haml gem automated conversion with decent accuracy. Complex templates needed manual cleanup afterward—the converter struggled with intricate Ruby logic or unusual HTML patterns. I converted frequently-edited templates first and left stable templates in ERB. The gradual approach avoided disrupting the team's workflow.
Tooling and editor support
The conversion tools revealed editor support differences. ERB works universally since it's HTML with minimal additions:
# ERB - universal editor support
- VSCode: Built-in HTML + Ruby extensions
- Sublime: Works out of the box
- Vim: html.erb filetype recognized
- RubyMine: Full support, autocomplete
- Emacs: web-mode handles ERB
# Syntax highlighting: Excellent
# Autocomplete: Full HTML/Ruby
# Linting: Standard HTML validators work
Every editor I used supported ERB through HTML modes with Ruby extensions. Syntax highlighting worked perfectly. Autocomplete suggested HTML tags and Ruby methods. HTML validators like HTMLHint caught structural errors. The tooling maturity came from ERB's longevity and HTML compatibility.
Haml requires dedicated plugins:
# Haml - requires plugins
- VSCode: "Better Haml" extension
- Sublime: Haml package via Package Control
- Vim: vim-haml plugin
- RubyMine: Built-in support
- Emacs: haml-mode
# Syntax highlighting: Good with plugins
# Autocomplete: Limited, plugin-dependent
# Linting: haml-lint gem required
I installed Haml plugins for each editor the team used. Quality varied—VSCode's Haml support was solid, but Sublime's package lagged behind. Autocomplete worked for Haml syntax but not always for attributes or Ruby expressions. I added haml-lint to CI pipelines to catch syntax errors and enforce style rules.
Final thoughts
ERB works best when you value HTML familiarity and tooling maturity. It requires no learning curve for developers who know HTML, lets you copy markup directly from design tools, and enjoys universal editor support. It's the right choice for teams with designers who edit templates, projects with lots of third-party HTML integration, or applications where HTML compatibility outweighs terseness.
Haml excels when you want enforced structure and reduced repetition. The indentation-based syntax prevents tag mismatches, the shorthand reduces typing, and the clean output makes debugging easier. It fits teams where everyone knows Ruby well, projects with complex nested components, or applications where template quality matters more than HTML familiarity.
In short, choose ERB for compatibility and simplicity, or Haml for structure and conciseness. The right pick depends on your team's background and whether you value HTML's ubiquity or Haml's enforced consistency.