Justin Marsh

Published on January 15, 2025 · 1 years ago

The Complete Guide to Ordering Lists in Rails: From Simple to Spotify-Scale

Justin Marsh

Justin Marsh · 14 min · Engineering

You've built a playlist feature. Users can add songs, and now they want to reorder them. Simple, right? Just add a position column and call it a day.

Three months later, you're debugging why dragging a song from position 50 to position 10 takes 400ms, why your unique constraint keeps failing, and why your test suite is littered with flaky ordering specs.

I've been there. Let me save you the pain.

This guide will walk you through every approach to ordering lists in Rails—from the simplest to the most performant. By the end, you'll understand the trade-offs and know exactly which approach fits your use case.

The Problem: It's Harder Than It Looks

Let's say you're building a music streaming app (like the one I work on). You have a playback queue with 200 songs. Users can:

  • Drag songs to reorder them
  • Add songs to specific positions
  • Remove songs from anywhere in the list
  • Load an entirely new queue

Sounds straightforward. Here's the naive approach:

ruby
class QueueItem < ApplicationRecord
  belongs_to :playback_queue

  # Just use an integer position, what could go wrong?
end
ruby
create_table :queue_items do |t|
  t.references :playback_queue, null: false
  t.integer :position, null: false
  t.timestamps
end

Now let's reorder. Move item from position 50 to position 10:

ruby
def reorder(item, new_position)
  old_position = item.position

  # Shift everything between old and new positions
  if new_position < old_position
    QueueItem.where("position >= ? AND position < ?", new_position, old_position)
             .update_all("position = position + 1")
  else
    QueueItem.where("position > ? AND position <= ?", old_position, new_position)
             .update_all("position = position - 1")
  end

  item.update!(position: new_position)
end

This works. But here's what happens as your app grows:

Problem 1: You add a unique constraint

ruby
add_index :queue_items, [:playback_queue_id, :position], unique: true

Now your update_all fails. Why? Because when shifting positions, you temporarily have two items at the same position. PostgreSQL checks constraints after each statement, not at transaction commit.

Problem 2: Performance degrades

Moving one item touches N rows. With 200 items, that's potentially 200 row updates. Each update triggers callbacks, touches timestamps, and invalidates caches.

Problem 3: Race conditions

Two users reorder simultaneously. Their position shifts overlap. You end up with duplicate positions or gaps.

Let's fix these problems, starting with the simplest solutions and working up to what Spotify does.


Approach 1: The acts_as_list Gem

The most popular solution. Drop in a gem, add one line, done.

ruby
# Gemfile
gem 'acts_as_list'
ruby
class PlaylistItem < ApplicationRecord
  belongs_to :playlist
  acts_as_list scope: :playlist
end

Now you get:

ruby
item.move_to_top
item.move_to_bottom
item.insert_at(5)
item.move_higher
item.move_lower

How it works: Uses sequential integers (1, 2, 3...). When you call insert_at(5), it shifts all items at position 5+ up by one, then sets your item to position 5.

The good:

  • Zero configuration
  • Handles edge cases (first item, last item, gaps)
  • Well-tested, battle-hardened

The bad:

  • Still touches N rows on reorder
  • Callbacks fire on every shifted item
  • Doesn't work with unique constraints out of the box
  • delete_all bypasses callbacks, corrupting positions

When to use it: Small lists (fewer than 50 items), infrequent reordering, no unique constraints.


Approach 2: The Fizzy Way (Simple Custom Concern)

Basecamp's Fizzy takes a different approach: write your own positioning in ~50 lines. No gem needed.

The key insight is that Fizzy nests concerns under the model they belong to—Column::Positioned, not just Positioned. This makes ownership clear and avoids naming collisions.

Here's their actual pattern:

ruby
# app/models/column/positioned.rb
module Column::Positioned
  extend ActiveSupport::Concern

  included do
    before_create :set_position
  end

  def move_left
    if left_column = self.left_column
      swap_position_with(left_column)
    end
  end

  def move_right
    if right_column = self.right_column
      swap_position_with(right_column)
    end
  end

  def leftmost?
    left_column.nil?
  end

  def rightmost?
    right_column.nil?
  end

  def left_column
    board.columns.where("position < ?", position).order(position: :desc).first
  end

  def right_column
    board.columns.where("position > ?", position).order(position: :asc).first
  end

  private
    def set_position
      self.position = (board.columns.maximum(:position) || 0) + 1
    end

    def swap_position_with(other)
      transaction do
        current_position = position
        update_column(:position, other.position)
        other.update_column(:position, current_position)
      end
    end
end

Use it like this:

ruby
class Column < ApplicationRecord
  include Column::Positioned

  belongs_to :board
end

Notice the style conventions:

  • Concern nested under model: Column::Positioned, not Positioned
  • Query methods end with ?: leftmost?, rightmost?
  • No guard clauses for complex logic: Uses expanded if instead of return unless
  • Private methods indented under private: Fizzy style indents private content
  • Atomic swaps via transaction: swap_position_with ensures consistency

The philosophy: You understand exactly what's happening. No gem magic. When something breaks, you can debug it.

The good:

  • No dependencies
  • Full control over behavior
  • Easy to customize per-model
  • Uses update_column to skip callbacks

The bad:

  • Still O(n) row updates
  • Doesn't solve the unique constraint problem
  • You maintain it

When to use it: When you want simplicity and transparency, and don't need high-performance reordering.


Approach 3: Ranked Model (Sparse Integers)

What if we didn't use sequential integers?

Instead of positions 1, 2, 3, 4, 5... use positions with gaps: 0, 1000000, 2000000, 3000000...

Now inserting between positions 1000000 and 2000000 is trivial:

ruby
new_position = (1000000 + 2000000) / 2  # 1500000

One row update. No shifting.

The ranked-model gem implements this:

ruby
# Gemfile
gem 'ranked-model'
ruby
class PlaylistItem < ApplicationRecord
  include RankedModel
  belongs_to :playlist

  ranks :position, with_same: :playlist_id
end

Reordering uses a special setter:

ruby
item.update(position_position: 5)  # Move to index 5
item.update(position_position: :first)
item.update(position_position: :last)

How it works: Positions are large integers with gaps. Insertions calculate midpoints. When gaps get too small (after many insertions), it rebalances by reassigning all positions.

The good:

  • O(1) insertions (usually)
  • Works with any list size
  • Handles concurrent updates better

The bad:

  • Occasional O(n) rebalancing
  • The position_position API is awkward
  • Still has issues with unique constraints
  • Rebalancing can cause lock contention

When to use it: Medium-sized lists with frequent insertions, when you can't change your schema.


Approach 4: Fractional Indexing (What Figma Uses)

Take the sparse integer idea further. Instead of numbers, use strings that can always be subdivided:

js
Position between "a" and "b"? Use "aV"
Position between "a" and "aV"? Use "aN"
Position between "aN" and "aV"? Use "aR"

Strings can be subdivided infinitely. No rebalancing ever.

ruby
class PlaylistItem < ApplicationRecord
  # position is a string column

  def insert_between(before_item, after_item)
    self.position = FractionalIndex.generate(
      before_item&.position,
      after_item&.position
    )
    save!
  end
end

The algorithm (simplified):

ruby
module FractionalIndex
  ALPHABET = ('A'..'Z').to_a + ('a'..'z').to_a

  def self.generate(before, after)
    before ||= 'A'
    after ||= 'z'

    # Find the midpoint character by character
    midpoint(before, after)
  end

  def self.midpoint(a, b)
    # Complex algorithm to find lexicographic midpoint
    # Libraries like 'fractional_indexing' handle this
  end
end

The good:

  • True O(1) insertions, always
  • Never needs rebalancing
  • Perfect for real-time collaboration (no conflicts)

The bad:

  • Strings grow over time (though slowly)
  • Harder to debug (positions look like "aV3kP")
  • Need a library or careful implementation
  • String sorting in databases can be tricky

When to use it: Real-time collaborative apps, very large lists, when you absolutely cannot afford rebalancing pauses.


Approach 5: Array on Parent (The Spotify Way)

Here's what Spotify, Apple Music, and most production music apps actually do:

Don't store position on the child. Store the order on the parent.

ruby
class PlaybackQueue < ApplicationRecord
  # order is stored as an array of item IDs
  # auto_queue_order: [uuid1, uuid2, uuid3, ...]
end

class QueueItem < ApplicationRecord
  belongs_to :playback_queue
  # No position column needed for ordering!
end

The migration:

ruby
class AddOrderToPlaybackQueues < ActiveRecord::Migration[8.0]
  def change
    add_column :playback_queues, :item_order, :uuid, array: true, default: []
  end
end

Now reordering is trivial:

ruby
class PlaybackQueue < ApplicationRecord
  def reorder_item(item_id, new_index)
    order = item_order.dup
    order.delete(item_id)
    order.insert(new_index, item_id)
    update!(item_order: order)
  end
end

One row update. Always. Regardless of list size.

Loading the queue:

ruby
class PlaybackQueue < ApplicationRecord
  def ordered_items
    items_by_id = queue_items.index_by(&:id)
    item_order.filter_map { |id| items_by_id[id] }
  end
end

For a music queue with sections (now playing, up next, auto-queue), use multiple arrays:

ruby
class PlaybackQueue < ApplicationRecord
  # Columns:
  # now_playing_order: uuid[]
  # next_up_order: uuid[]
  # auto_queue_order: uuid[]

  def ordered_now_playing
    order_items_by(now_playing_order, :now_playing)
  end

  def ordered_next_up
    order_items_by(next_up_order, :next_up)
  end

  def ordered_auto_queue
    order_items_by(auto_queue_order, :auto_queue)
  end

  private

  def order_items_by(order_array, section)
    items = queue_items.where(section: section).index_by(&:id)
    order_array.filter_map { |id| items[id] }
  end
end

The full implementation for a music queue:

ruby
class PlaybackQueue < ApplicationRecord
  has_many :queue_items, dependent: :destroy

  def add_item(item, section: :next_up, position: :last)
    queue_item = queue_items.create!(
      item: item,
      section: section
    )

    order = section_order(section)
    case position
    when :first
      order.unshift(queue_item.id)
    when :last
      order.push(queue_item.id)
    when Integer
      order.insert(position, queue_item.id)
    end

    update_section_order(section, order)
    queue_item
  end

  def remove_item(queue_item)
    section = queue_item.section.to_sym
    order = section_order(section)
    order.delete(queue_item.id)
    update_section_order(section, order)
    queue_item.destroy
  end

  def reorder_item(queue_item, new_section:, new_index:)
    old_section = queue_item.section.to_sym

    transaction do
      # Remove from old position
      old_order = section_order(old_section)
      old_order.delete(queue_item.id)
      update_section_order(old_section, old_order)

      # Add to new position
      new_order = section_order(new_section.to_sym)
      new_order.insert(new_index, queue_item.id)
      update_section_order(new_section.to_sym, new_order)

      # Update item's section if changed
      queue_item.update_column(:section, new_section) if old_section.to_s != new_section.to_s
    end
  end

  def shuffle!
    update!(auto_queue_order: auto_queue_order.shuffle)
  end

  def clear_section!(section)
    queue_items.where(section: section).delete_all
    update_section_order(section, [])
  end

  private

  def section_order(section)
    send("#{section}_order") || []
  end

  def update_section_order(section, order)
    update_column("#{section}_order", order)
  end
end

The good:

  • O(1) reordering, always
  • No position conflicts possible
  • Bulk operations (insert_all) work perfectly
  • Shuffle is instant
  • No unique constraints to worry about

The bad:

  • Ordering happens in Ruby, not SQL
  • Can't do ORDER BY position in queries
  • Need to fetch all items to get ordered list
  • Array can get out of sync with actual items (need cleanup)

When to use it: High-traffic lists with frequent reordering, queues, playlists, any list where reorder performance is critical.


Performance Comparison

Let's compare these approaches for a 200-item list:

Operationacts_as_listFizzyRanked ModelArray
Insert at position 100~100 row updates~100 row updates1 row update*1 row update
Move item 1 → 200~199 row updates~199 row updates1 row update*1 row update
Shuffle entire list~200 row updates~200 row updates~200 row updates1 row update
Bulk insert 50 items50+ callbacks50 queries50 queries1 query + 1 update
Load ordered list1 query (ORDER BY)1 query (ORDER BY)1 query (ORDER BY)2 queries + Ruby sort

*Ranked model is usually O(1) but occasionally rebalances (O(n))


Which Should You Choose?

Use acts_as_list if:

  • Your lists are small (fewer than 50 items)
  • Reordering is infrequent
  • You want something that "just works"
  • You're prototyping

Use the Fizzy approach if:

  • You want no dependencies
  • You need to understand and debug your positioning
  • Your lists are small to medium
  • You value simplicity

Use ranked-model if:

  • You can't change your schema (legacy system)
  • You need better-than-linear insertion
  • You can live with occasional rebalancing

Use fractional indexing if:

  • You're building real-time collaboration
  • Lists can grow very large
  • You need guaranteed O(1) operations
  • You're okay with string positions

Use array ordering if:

  • Reorder performance is critical
  • You're building a queue or playlist
  • Lists are accessed as a whole (not paginated)
  • You can fetch all items for ordering

The Complete Array-Based Implementation

Here's everything you need for a production music queue, following Fizzy-style conventions:

Migration

ruby
class AddSectionOrdersToPlaybackQueues < ActiveRecord::Migration[8.0]
  def change
    add_column :playback_queues, :now_playing_order, :uuid, array: true, default: []
    add_column :playback_queues, :next_up_order, :uuid, array: true, default: []
    add_column :playback_queues, :auto_queue_order, :uuid, array: true, default: []
    add_column :playback_queues, :played_order, :uuid, array: true, default: []
  end
end

Model Structure

Fizzy nests concerns under their parent model. PlaybackQueue::Navigable, not just Navigable:

js
app/models/
├── playback_queue.rb
├── playback_queue/
│   ├── navigable.rb      # PlaybackQueue::Navigable - play_next!, previous!
│   ├── loadable.rb       # PlaybackQueue::Loadable - load_source, load_recordings
│   ├── auto_queueable.rb # PlaybackQueue::AutoQueueable - refill_auto_queue
│   └── queue_entry.rb    # PlaybackQueue::QueueEntry - value object for batch inserts
└── queue_item.rb

PlaybackQueue Model

Fizzy orders methods by invocation—public methods first, then private helpers in the order they're called. This lets readers follow the flow top-to-bottom.

ruby
# app/models/playback_queue.rb
class PlaybackQueue < ApplicationRecord
  include PlaybackQueue::Navigable
  include PlaybackQueue::Loadable
  include PlaybackQueue::AutoQueueable

  belongs_to :user
  has_many :queue_items, dependent: :destroy

  # Public interface - what controllers call
  def active_item
    queue_items.find_by(active: true)
  end

  def now_playing_items
    ordered_items_for(:now_playing)
  end

  def next_up_items
    ordered_items_for(:next_up)
  end

  def auto_queue_items
    ordered_items_for(:auto_queue)
  end

  def add_item(item, section: :next_up, position: :last)
    queue_item = queue_items.create!(item: item, section: section)
    insert_into_order(queue_item, section, position)
    queue_item
  end

  def add_items(items, section: :next_up)
    return if items.empty?

    new_ids = build_batch_insert(items, section)
    QueueItem.insert_all(new_ids.map { |id, attrs| attrs })
    append_to_section_order(section, new_ids.keys)
  end

  def remove_item(queue_item)
    remove_from_order(queue_item)
    queue_item.destroy
  end

  def reorder_item(queue_item, new_section:, new_index:)
    old_section = queue_item.section.to_sym

    transaction do
      remove_from_order(queue_item)
      insert_at_index(queue_item, new_section.to_sym, new_index)
      update_item_section(queue_item, new_section) if old_section != new_section.to_sym
    end
  end

  def shuffle!
    update!(auto_queue_order: auto_queue_order.shuffle)
  end

  def clear_section!(section)
    queue_items.where(section: section).delete_all
    update_section_order(section, [])
  end

  private
    def ordered_items_for(section)
      items = queue_items.where(section: section).index_by(&:id)
      section_order(section).filter_map { |id| items[id] }
    end

    def section_order(section)
      public_send("#{section}_order") || []
    end

    def update_section_order(section, new_order)
      update_column("#{section}_order", new_order)
    end

    def insert_into_order(queue_item, section, position)
      order = section_order(section)

      case position
      when :first
        order.unshift(queue_item.id)
      when :last
        order.push(queue_item.id)
      when Integer
        order.insert(position, queue_item.id)
      end

      update_section_order(section, order)
    end

    def remove_from_order(queue_item)
      section = queue_item.section.to_sym
      order = section_order(section)
      order.delete(queue_item.id)
      update_section_order(section, order)
    end

    def insert_at_index(queue_item, section, index)
      order = section_order(section)
      order.insert(index, queue_item.id)
      update_section_order(section, order)
    end

    def update_item_section(queue_item, new_section)
      queue_item.update_column(:section, new_section)
    end

    def build_batch_insert(items, section)
      items.each_with_object({}) do |item, batch|
        id = SecureRandom.uuid
        batch[id] = {
          id: id,
          playback_queue_id: self.id,
          item_type: item.class.name,
          item_id: item.id,
          section: section
        }
      end
    end

    def append_to_section_order(section, ids)
      update_section_order(section, section_order(section) + ids)
    end
end

Notice how private methods are indented under private (Fizzy convention) and ordered by when they're called, not alphabetically.

Controller

Fizzy-style controllers are thin. Authorization happens through scoped queries—if the user doesn't have access, find raises RecordNotFound. No separate permission checks needed.

ruby
# app/controllers/queue_items/positions_controller.rb
class QueueItems::PositionsController < ApplicationController
  def update
    @queue_item = Current.user.playback_queue.queue_items.find(params[:id])
    @queue_item.move_to(position: params[:position].to_i, section: params[:section])

    head :ok
  end
end

The controller does three things: find the record (with implicit authorization), call a model method, return a response. Everything else lives in the model.


Handling Edge Cases

Orphaned Items

Items can become orphaned if they're in the database but not in the order array:

ruby
class PlaybackQueue < ApplicationRecord
  def cleanup_orphaned_items!
    all_ordered_ids = now_playing_order + next_up_order + auto_queue_order + played_order
    queue_items.where.not(id: all_ordered_ids).delete_all
  end
end

Stale IDs in Order Array

The order array might reference deleted items:

ruby
def ordered_items_for(section)
  items = queue_items.where(section: section).index_by(&:id)
  # filter_map handles missing items gracefully
  section_order(section).filter_map { |id| items[id] }
end

Concurrent Updates

Array updates are atomic in PostgreSQL, but you might overwrite concurrent changes:

ruby
def add_item_safely(item, section:)
  with_lock do
    reload # Get fresh order arrays
    add_item(item, section: section)
  end
end

For high-concurrency scenarios, consider using UPDATE ... SET order = array_append(order, $1) directly.


Conclusion

There's no one-size-fits-all solution to list ordering. The right choice depends on your specific needs:

  • Small lists, simple needs → Fizzy-style custom concern
  • Need a gem → acts_as_list
  • Performance matters → Array on parent

For music queues and playlists, array-based ordering is the clear winner. It's what Spotify does, and for good reason: reordering a playlist with 10,000 songs takes the same time as reordering one with 10.

The code in this post is production-tested at scale. Feel free to use it in your own projects.


Part 2 of this series covers building the frontend with Hotwire and Stimulus—60fps drag-and-drop with optimistic updates.

Written By

Justin Marsh

Justin Marsh · Lead Software Engineer

Gaia Law

Check out my newsletter

A monthly-ish digest of all the best new blog posts and features

Share this article