Model or dataset
ClosureTree/closure_tree avatar
ClosureTree/closure_tree

closure_tree: hierarchies in ActiveRecord with one SELECT per question

Easily and efficiently make your ActiveRecord models support hierarchies

1,944 stars245 forksRubyMIT

At a glance

What is it?
closure_tree stores a closure table beside your model, which is why ancestors, descendants, and siblings all cost a single SELECT. The setup has three ordering constraints that the README is unusually firm about.
Who is it for?
Adopt closure_tree if your hierarchy is a real tree and read volume beats write volume, since the closure table trades extra INSERTs on reparenting for single-SELECT subtree reads. Do not adopt it on a model that already has another hierarchy gem, and reach for the companion dag_me gem instead if a node legitimately has multiple parents.
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 18 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 23, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • ClosureTree/closure_tree1,944 ★ · +0
Star history
Repository2026-10-162026-10-23
ClosureTree/closure_tree1.9k1.9k

0 Stars in the last 7 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

Why the closure table exists, stated in INSERT counts

A hierarchy stored as a single `parent_id` column per row is cheap to write and expensive to read. Reading all descendants of a node means recursive queries, and reading all ancestors means walking upward with one query per level. closure_tree's answer is a second table holding every ancestor-descendant pair, so a subtree query becomes a join on that table rather than a recursion.

The README states the cost symmetrically, which is the most useful way to evaluate it. Selects: the whole ancestor lineage in 1 SELECT, all descendants in 1 SELECT, all siblings in 1 SELECT, descendants as a nested hash in 1 SELECT, and finding a node by ancestry path in 1 SELECT. Mutations: 2 SQL INSERTs on node creation, 3 SQL INSERT/UPDATEs on node reparenting.

So the shape is a deliberate skew. Every write costs more because every row of the new subtree has to be reinserted into the closure table. Many common reads can be completed with a single SELECT. If your hierarchy is read constantly and reshaped rarely, that is a good trade. If a user drags nodes around a tree all day, you are paying three writes per move.

The project also points at Bill Karwin's presentation on models for hierarchical data, which is the reference for the storage algorithms themselves rather than for this gem.

Three setup constraints that will bite you out of order

Installation is seven steps and three of them are ordering rules that look trivial until they are wrong.

First, `has_closure_tree` must be added after `attr_accessible` and `self.table_name =` lines in the model, and the README marks this IMPORTANT. Second, the `parent_id` column must be nullable, because root nodes have a NULL `parent_id`. Third, the generated migration must run after that column exists.

ruby
class Tag < ApplicationRecord
  has_closure_tree
end

class AnotherTag < ApplicationRecord
  acts_as_tree
end

`acts_as_tree` is an alias of `has_closure_tree`, and there is a deliberate guard around it: the alias is only created if your model does not already have a method with that name, which prevents a clash with the original `acts_as_tree` gem.

Then the migration for the foreign key column:

ruby
class AddParentIdToTag < ActiveRecord::Migration[7.2]
  def change
    add_column :tags, :parent_id, :integer
  end
end

Finally, the generator creates the closure table itself with `rails g closure_tree:migration tag`, and `rake db:migrate` applies it. The table name defaults to the model table name plus `_hierarchies`, so `tags_hierarchies`. Alongside it a virtual model, `TagHierarchy` in this example, is created dynamically and you do not create it yourself.

One more case is covered explicitly: if you are migrating from a system that already has a `parent_id` column, run `Tag.rebuild!`, which truncates and rebuilds the `tag_hierarchies` table. Starting fresh, you do not need to call it.

Five ways to make a child, all equivalent in the database

The API surface for creation is deliberately wide, and the examples read as a list of equivalent idioms rather than a hierarchy of concepts.

ruby
grandparent = Tag.create(name: 'Grandparent')
ruby
parent = grandparent.children.create(name: 'Parent')
ruby
child2 = Tag.new(name: 'Second Child')
parent.children << child2
ruby
child3 = Tag.new(name: 'Third Child')
parent.add_child child3
ruby
Tag.create(name: 'Fourth Child', parent: parent)

Five spellings of the same operation: create directly, create through the children collection, push onto the collection, call `add_child`, or set `parent` in the constructor. Pick one per codebase, because mixing them in adjacent lines reads as though they mean different things.

The read side is where the closure table pays off, and the methods mirror the structure:

ruby
grandparent.self_and_descendants.collect(&:name)
#=> ["Grandparent", "Parent", "First Child", "Second Child", "Third Child", "Fourth Child"]

child1.ancestry_path
#=> ["Grandparent", "Parent", "First Child"]

`self_and_descendants` returns the node plus everything below it, and `ancestry_path` returns the lineage from the root down. Both are the operations that would be recursive queries without this gem.

find_or_create_by_path builds trees from strings

The method that changes how you write code is `find_or_create_by_path`. It takes an array of path segments, each referencing the `name` column, and creates whatever part of the chain is missing:

ruby
child = Tag.find_or_create_by_path(%w[grandparent parent child])

The column used for the segments is configurable through the `:name_column` option passed to `has_closure_tree`, so this works for models keyed by slug or code rather than name. Because the README's single-SELECT claim covers finding a node by ancestry path, this is also a read operation: the parts that already exist are looked up in one query rather than one per level.

Since v5.0.0 the array entries can be attribute hashes instead of strings, which lets you set fields on each level as you build it:

ruby
child = Tag.find_or_create_by_path([
  {name: 'Grandparent', title: 'Sr.'},
  {name: 'Parent', title: 'Mrs.'},
  {name: 'Child', title: 'Jr.'}
])

With single-table inheritance, the hashes can carry `sti_name` and the behaviour holds, so a path can cross classes in a hierarchy, with each level carrying a `type` such as `DateLabel` alongside its attributes.

Note what this is not. It is not a bulk import with validation or transaction semantics spelled out, and a typo in a path segment silently creates a new node rather than failing. That makes it excellent for seeding fixtures and building out a hierarchy incrementally, and dangerous as the only way to populate production data.

One hierarchy gem per model, no exceptions

The README has a Warning section that is unusual in tone and unambiguous in content: using multiple hierarchy gems such as ancestry or a nested set on the same model will most likely result in pain and various ailments, and you should assume things will break. If you are already using another hierarchical gem, the installation instructions point you at this section.

The reason is structural rather than ideological. closure_tree adds a second table alongside the model, but the README warns against combining hierarchy gems on the same model. Install two and you have two sources of truth for the same relationship, and each library will read its own and write its own.

So the decision is per model, made once. Switching from another gem to this one is not an incremental change; it is a migration with a data rebuild, which is why `rebuild!` exists and why the setup instructions handle the case where you already have a `parent_id` column.

The other boundary is structural rather than competitive. If a node needs multiple parents, this is the wrong gem, because a tree has one parent per node by definition. The README directs that case to the companion gem dag_me, which brings multi-parent directed acyclic graphs to ActiveRecord and requires PostgreSQL 18 or later. That is a real constraint on your database version if your hierarchy is genuinely a DAG, and it is better to discover it now than after modelling.

Ordering, traversal, DOT output, and STI inside the tree

Several features exist for the cases where a tree is not just a tree to read but a thing to display or verify.

Deterministic ordering is optional and requires an extra column on your model, which the setup section mentions alongside the `parent_id` migration. Without it, sibling order is whatever the database returns, and a rendered menu that reshuffles between requests is a bug report waiting to happen.

Traversal of descendants can be preordered, which is the order you want for rendering since it means a depth-first walk without a stack.

Trees can be rendered in DOT format for Graphviz, which turns tree debugging from a log statement into a picture. This is the feature that earns its keep when a reparent has produced something unexpected and you need to see the shape.

Single-table inheritance works within the hierarchy, so different classes can occupy different levels of the same tree. Combined with the `find_or_create_by_path` behaviour described above, you can build a heterogeneous hierarchy where the class of each node is chosen as part of the path.

Concurrency is supported through the companion gem with_advisory_lock.

Supported stack, test matrix, and licence

closure_tree only supports ActiveRecord 7.2 and later, and the README states it is tested against ActiveRecord 7.2+ with Ruby 3.3+. That floor is worth checking against your app before anything else, because it rules out the older Rails versions a long-lived application may still be on.

Test coverage spans MySQL, PostgreSQL, and SQLite, and the repository ships a docker-compose file for the first two rather than relying on the developer having them installed. PostgreSQL runs as `postgres:17-alpine` on port 5434 and MySQL as `mysql:8` on port 3367, with matching credentials. The `.env.example` shows the connection strings:

code
DATABASE_URL_PG=postgresql://closure_tree:[email protected]:5434/closure_tree_test
DATABASE_URL_MYSQL=mysql2://closure_tree:[email protected]:3367/closure_tree_test

SQLite is in-memory by default and can be set through `DATABASE_URL_SQLITE3`. Running the suite against all three engines before trusting a behaviour is the cheapest way to avoid the portability surprises that come with closure tables and column types.

The licence is MIT, in `MIT-LICENSE` at the root. Releases move through release-please, with v9.6.2 and v9.7.0 both dated 2026-05-06 and v9.8.0 on 2026-08-05, and the last push was on 2026-10-06. A major version number in the high nines on a mature gem means the API has had a long time to settle, which is a point in its favour for anything long-lived.

Editorial conclusion

Adopt closure_tree if your hierarchy is a real tree and read volume beats write volume, since the closure table trades extra INSERTs on reparenting for single-SELECT subtree reads. Do not adopt it on a model that already has another hierarchy gem, and reach for the companion dag_me gem instead if a node legitimately has multiple parents. Verify first that your existing parent_id column is nullable, because root nodes store NULL there and a NOT NULL constraint will break root creation.

Frequently asked questions

How do I install closure_tree in a Rails application?

Add `gem 'closure_tree'` to your Gemfile, run `bundle install`, add `has_closure_tree` to your model after any `attr_accessible` and `self.table_name =` lines, add a nullable `parent_id` column through a migration, then run `rails g closure_tree:migration tag` and `rake db:migrate`. It requires ActiveRecord 7.2 or later.

What are the database requirements for closure_tree?

The parent_id column must be nullable, since root nodes store NULL there, and a second table named after your model table plus `_hierarchies` holds the closure rows. The gem has test coverage for MySQL, PostgreSQL, and SQLite, and the repository ships a docker-compose file running PostgreSQL and MySQL.

Can I use closure_tree on a model that already uses another hierarchy gem?

No. The README warns that combining hierarchy gems on the same model will break, since each owns the parent_id column and stores the relationship differently. If your nodes have multiple parents, a tree is the wrong structure and the companion dag_me gem is the recommended alternative.

Official sources

  1. ClosureTree/closure_tree on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
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.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/closuretree-closure-tree.svg)](https://hysenlabs.com/projects/closuretree-closure-tree)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/closuretree-closure-tree.svg)](https://hysenlabs.com/projects/closuretree-closure-tree)

Was this useful?