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:
class QueueItem < ApplicationRecord
belongs_to :playback_queue
# Just use an integer position, what could go wrong?
end
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:
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
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.
# Gemfile
gem 'acts_as_list'
class PlaylistItem < ApplicationRecord
belongs_to :playlist
acts_as_list scope: :playlist
end
Now you get:
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_allbypasses 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:
# 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:
class Column < ApplicationRecord
include Column::Positioned
belongs_to :board
end
Notice the style conventions:
- Concern nested under model:
Column::Positioned, notPositioned - Query methods end with
?:leftmost?,rightmost? - No guard clauses for complex logic: Uses expanded
ifinstead ofreturn unless - Private methods indented under
private: Fizzy style indents private content - Atomic swaps via transaction:
swap_position_withensures 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_columnto 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:
new_position = (1000000 + 2000000) / 2 # 1500000
One row update. No shifting.
The ranked-model gem implements this:
# Gemfile
gem 'ranked-model'
class PlaylistItem < ApplicationRecord
include RankedModel
belongs_to :playlist
ranks :position, with_same: :playlist_id
end
Reordering uses a special setter:
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_positionAPI 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:
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.
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):
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.
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:
class AddOrderToPlaybackQueues < ActiveRecord::Migration[8.0]
def change
add_column :playback_queues, :item_order, :uuid, array: true, default: []
end
end
Now reordering is trivial:
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:
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:
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:
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 positionin 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:
| Operation | acts_as_list | Fizzy | Ranked Model | Array |
|---|---|---|---|---|
| Insert at position 100 | ~100 row updates | ~100 row updates | 1 row update* | 1 row update |
| Move item 1 → 200 | ~199 row updates | ~199 row updates | 1 row update* | 1 row update |
| Shuffle entire list | ~200 row updates | ~200 row updates | ~200 row updates | 1 row update |
| Bulk insert 50 items | 50+ callbacks | 50 queries | 50 queries | 1 query + 1 update |
| Load ordered list | 1 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
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:
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.
# 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.
# 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:
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:
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:
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.
