ranked-model: sortable row ordering for Rails without rewriting the table
An acts_as_sortable/acts_as_list replacement built for Rails 4+
At a glance
- What is it?
- A Rails concern that keeps an integer ranking column on your model and lets you move rows with a fake `_position` attribute, using spaced-out integers so a reordering touches one row instead of renumbering the list. Its two sharp edges are a nullable column requirement and an N+1 trap when you ask each record for its own rank.
- Who is it for?
- ranked-model suits Rails applications that need user-defined ordering on an existing table and cannot add a separate ordering table, and where the writer of a reorder request is trusted enough to be given a position value. It does not suit a model whose ordering column cannot be nullable, because the gem requires nulls, and it does not suit code that renders a long list by asking each record for its own rank, since that produces one query per record.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 5 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What ranked-model replaces and why
ranked-model is described as a modern row sorting library built for Rails 4.2 and above, and as an `acts_as_sortable` and `acts_as_list` replacement. The README says it uses ARel aggressively and is better optimized than most other libraries, and it is careful to attribute that claim to its own approach rather than to a benchmark.
The problem it addresses is the one every sortable list hits: a user drags row 3 to row 1, and now the order of many rows has changed. The naive implementation stores an integer position per row and renumbers everything between the old and new positions, which turns a single drag into a write to every affected row. ranked-model instead stores big, widely spaced integers, so a move only has to change the integer of the row that moved. The README says the ranking integers will be big and spaced apart, and the STI example below shows a value of 4194304 appearing, which is a power-of-two style spacing.
The API surface is small. A model includes the concern, names a ranking column, and you query through a `rank` scope:
class Duck < ActiveRecord::Base
include RankedModel
ranks :row_order
endDuck.rank(:row_order).allThe library is written in Ruby, is MIT licensed, and the repository carries a `LICENSE` file, a `CHANGELOG.md`, a `Rakefile`, a `Gemfile`, an `Appraisals` file and a `gemfiles/` directory for testing against multiple dependency sets, plus a `spec/` directory and a `.rspec` file.
One provenance detail matters if you plan to depend on it. The repository's homepage points at `github.com/mixonic/ranked-model`, and the install snippet in the README pins the gem to that same upstream with `git: '[email protected]:mixonic/ranked-model.git'`.
The nullable column requirement and the migration
The README puts one requirement in bold and all caps: the `_order` table column must allow null values, with a link to issue 167 for the reason behind it. This is the sort of detail that silently breaks a migration if you skip it.
The setup is a standard generator plus a migration:
rails g migration AddRowOrderToDucks row_order:integer
rails db:migrateA single integer column on the table, and that is the entire schema cost. For existing data the README gives the same generator command and then tells you to adjust the migration, with the instruction to declare `include RankedModel` and `ranks :row_order` in the model before running it, so the gem can populate the existing rows as it goes.
The reason the column has to be nullable is structural rather than a quirk. New records that have never been placed in the list need to be able to carry a null position, and a not-null column with a default would give every new row the same value and make the ordering ambiguous. If your existing table already has a non-null integer ordering column, the migration has to change the column definition before the gem can be used, and that is a schema change with a lock on large tables depending on your database.
That is a real adoption cost, and it is the kind you only discover when the constraint bites. Checking whether your current ordering column allows null before anything else saves an afternoon.
The fake _position column and how a reorder request looks
Reordering happens through a fake column. You append `_position` to the ranking column name and assign the target position, and the gem turns that into a change of the real ranking integer. The README is emphatic that you must append `_position`, calling it a fake column that takes relative as well as absolute index-based values.
@duck.update row_order_position: 0 # or 1, 2, 37. :first, :last, :up and :down are also validThe accepted values are an integer index, or the symbols `:first`, `:last`, `:up` and `:down`. Positions begin at zero, and a position number greater than the number of records acts the same as `:last`.
Because the value is settable through ordinary attribute assignment, the update fits into a standard Rails controller, and the README shows the JavaScript that results:
$.ajax({
type: 'PUT',
url: '/ducks',
dataType: 'json',
data: { duck: { row_order_position: 0 } }, // or whatever your new position is
});This is the design decision that makes the library pleasant in a Rails app: no bespoke reorder endpoint, no separate position model, just an attribute write. The cost is that the attribute is writable by anything that can set model attributes, so the authorization on that controller is what keeps a client from moving rows it should not be able to move.
The companion read API is the `{column_name}_rank` method, which returns the position of a record with respect to the others in the list:
Duck.rank(:row_order).first.row_order_rank # => 0
Duck.rank(:row_order).third.row_order_rank # => 2Note that the `rank` scope returns an ordered relation, and the rank method is what translates that order into a number.
Scoping the ranking with_same, scope and unless
The `ranks` method takes several arguments, and the complex example in the README shows the combinations that matter in a real model.
ranks :row_order, # Name this ranker, used with rank()
column: :sort_order # Override the default column, which defaults to the name
belongs_to :pond
ranks :swimming_order,
with_same: :pond_id # Ducks belong_to Ponds, make the ranker scoped to one pond
ranks :row_order,
with_same: [:pond_id, :breed] # Lets rank them by breed
scope :walking, where(walking: true )
ranks :walking_order,
scope: :walking # Narrow this ranker to a scope
belongs_to :parent, class_name: 'Duck', optional: true
ranks :child_order,
unless: :has_no_parent?, # Rank only ducks that have a parent. Alternatively a Proc or lambda can be passed, e.g. proc { parent.nil? }
with_same: :parent_id`with_same` is the partitioning option and it takes a column name or an array of column names, which is how you get one list per pond and one list per breed on the same table. `scope` narrows the ranker to an ActiveRecord scope. `unless` excludes records from the ranking entirely, and accepts a method name, a Proc or a lambda, so a duck with no parent is simply not in the child ordering.
The `column` option renames the underlying column, which is separate from the ranker name used with `rank()`. That separation is the key to having several independent orderings on one model, and the same model in the example holds a `row_order`, a `swimming_order`, a `walking_order` and a `child_order`.
Queries then add the rank to the relation:
Duck.rank(:row_order)
Pond.first.ducks.rank(:swimming_order)
Duck.walking.rank(:walking)The N+1 trap when reading ranks one record at a time
The README has a section titled Drawbacks, and it names one: while ranked-model is performant when storing data, it might cause N+1 queries depending on how you write your code. The example is a plain listing.
ducks = Duck.all
ducks.map do |duck|
{
id: duck.id,
position: duck.row_order_rank # This causes N+1!
}
endEvery call to `duck.row_order_rank` makes a database call to check the rank of that particular element, so the number of queries scales with the length of the list. The README warns that on a long list this might cause issues for the database.
The fix it recommends is to use the rank scope and derive the position in Ruby instead:
ducks = Duck.rank(:row_order).all
ducks.map.with_index do |duck, index|
{
id: duck.id,
position: index
}
endThis is a normal consequence of computing a rank as a count of rows before the current one, and it is worth being explicit about the shape of the problem: the library is not slow at storing order, it is slow at answering rank queries per object. Any view that renders a ranked list has to fetch the ranked relation once and enumerate it, and the `with_index` approach is the idiom to keep.
The README does not document a batch or prefetch API for ranks, and none appears in the description of the options, so the scope-plus-index approach is the documented answer rather than an optimisation you have to discover.
Single table inheritance silently splits your ranking
The STI section describes a behaviour that will bite anyone who uses a `type` column and does not read this part of the README.
ranked-model scopes a record's positions based on the class name of the object. With a STI `type` column, a `Car` and a `Truck` both inherit the `ranks :row_order` declaration from `Vehicle`, but they have different class names, so they end up in separate rankings and each starts at zero:
car = Car.create!
truck = Truck.create!
car.row_order
=> 0
truck.row_order
=> 0The fix is the `class_name` option, which pins the ranking to a base class so the subclasses share one ladder:
class Vehicle < ActiveRecord::Base
ranks :row_order, class_name: 'Vehicle'
endAfter that, the car is at 0 and the truck is at 4194304, the spaced value that the ranking scheme produces. That large number is not an anomaly, it is the design working: the gap leaves room to insert rows between them without renumbering.
The same idea generalises to the `with_same` option, and the STI case is really an instance of it. If you are using STI and your ranking is supposed to be global, set `class_name` at the same time you add the model concern, not after the first user notices two independent lists.
Maintenance, the upstream fork and the successor gem
The repository is not archived and the last push was on 2026-06-22, so work is landing on the default branch, which is master. There are no GitHub releases, so the version in the `ranked-model.gemspec` is the only version marker, and the `CHANGELOG.md` is the record of what changed.
The compatibility story in the README is dated in a way you should notice. It says the gem passes specs with Rails 4.2, 5.0, 5.1, 5.2, 6.0 and 6.1 for MySQL, Postgres and SQLite on Ruby 2.4 through 3.0, with exceptions, and asks you to check the CI setup for supported combinations, plus jruby-9.1.17.0 where Rails supports the platform. The repository also still carries a `.travis.yml`, which was the CI system before GitHub Actions, and there is a build badge pointing at a GitHub Actions workflow, so the continuous integration has moved on.
The bigger point is the announcement at the top of the README. The maintainer, who also maintains Acts As List and the Ranked Model gems, released a new gem called Positioning. The README describes it as working similarly to Acts As List in that it maintains a sequential list of integer values as positions, and differing in that it encourages a unique constraint on the position column and supports multiple lists per database table. It borrows the relative positioning concept from Ranked Model.
That is an admission that the current gem's design has a limit, and the limit is the one the STI and multiple-list options work around. If you are starting a new project rather than maintaining an existing ranked model, Positioning is the gem the maintainer is steering people toward, and the same author writes both.
The licence is MIT, so the obligations are light, but the choice between them is the one decision this project pushes on you.
Editorial conclusion
ranked-model suits Rails applications that need user-defined ordering on an existing table and cannot add a separate ordering table, and where the writer of a reorder request is trusted enough to be given a position value. It does not suit a model whose ordering column cannot be nullable, because the gem requires nulls, and it does not suit code that renders a long list by asking each record for its own rank, since that produces one query per record. Before adopting it, add the integer column with `rails g migration AddRowOrderToDucks row_order:integer` and leave it nullable, read the `_order` column rule behind issue 167, and decide whether the `class_name` option is needed for single table inheritance before writing the first reorder endpoint.
Frequently asked questions
How do I add row ordering to a Rails model with ranked-model?
Add an integer column with `rails g migration AddRowOrderToDucks row_order:integer`, then include `RankedModel` in the model and declare `ranks :row_order`. The column must allow null values, and `Duck.rank(:row_order).all` returns the rows in order.
How do I move a row to a new position in ranked-model?
Assign the fake `_position` attribute, for example `@duck.update row_order_position: 0`. You can also use the symbols `:first`, `:last`, `:up` and `:down`, and positions start at zero.
Why does ranked-model require a nullable ordering column?
The README states the requirement in bold and points to issue 167 for the reason. Records that have never been placed in a list need to hold a null position, which a not-null column with a default cannot express.
How do I avoid N+1 queries when displaying ranked items?
Fetch the records through the rank scope and use the array index instead of asking each record for its rank, since every call to `row_order_rank` triggers a database query. The README shows `ducks.map.with_index` as the replacement.
How does ranked-model handle single table inheritance?
Positions are scoped by class name, so a `Car` and a `Truck` get separate rankings that each start at zero. Use the `class_name` option on the `ranks` declaration to rank them together under a base class.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/brendon-ranked-model)Was this useful?