# Ancestry: materialized paths for ActiveRecord tree structures

> A long-lived Ruby gem that stores a node's whole ancestor chain in one column, so tree reads cost a single indexed query and moves cost a single write.

**stefankroes/ancestry** — Organise ActiveRecord model into a tree structure

- Repository: https://github.com/stefankroes/ancestry
- Stars: 3,885 · Forks: 474
- Language: Ruby
- License: MIT
- Published: 2026-10-08 · Updated: 2026-10-08 · Language: en
- Canonical page: https://hysenlabs.com/projects/stefankroes-ancestry

## The whole hierarchy lives in one column

Ancestry organizes Rails ActiveRecord models as a tree using the materialized path pattern. Each record stores its ancestor chain in a single column, written as something like 1/2/3/ with a trailing separator, and there are no extra tables. The README frames this as three properties in a row: one column, no extra tables, single-query reads.

Everything else follows from that layout. Because a node's full path is stored on the node itself, descendants are found by a prefix match on an indexed column, ancestors are found by splitting the same string, and root is the first segment. A btree index on that column is enough to serve all of it, which is why the README lists ancestors, descendants, siblings, children and leaves as single indexed queries and points at a third-party benchmark repository, kbrock/tree-bench, as evidence.

The cost shows up on writes. Moving a node means rewriting the path prefix on every node beneath it, so the README's phrasing is precise when it says moving a node only updates its descendants rather than the whole tree. On a wide tree with deep subtrees, that is a real write cost, and it is the side of the trade that rarely appears in feature lists. Deep trees also hit the column length limit of the database, which is what the format options are for.

The pattern itself is not exotic. Ancestry has been the default answer in Ruby for years, and knowing that it is a materialized path tells you most of what you need about its query behavior before reading any code.

## Installation is a migration helper, not a hand-rolled column

Adding the gem takes one line, then a migration generator that names the table:

```bash
$ bundle install
$ rails g migration add_ancestry_to_[table]
```

The generated migration calls a helper the gem adds to the migration DSL:

```ruby
class AddAncestryToTable < ActiveRecord::Migration[7.0]
  def change
    change_table(:table) do |t|
      t.ancestry
      # t.ancestry format: :materialized_path3, cache_depth: true, parent: true, counter_cache: true
    end
  end
end
```

The point of `t.ancestry` is that it creates the column with the correct type, collation and indexes for your database, and accepts options for cached columns and ancestry formats. A materialized path column is collation-sensitive, because prefix matching on a string depends on how the database sorts, and getting that wrong produces subtly wrong descendant queries rather than an error. Letting the helper do it removes a category of bug.

The model itself takes one line:

```ruby
class [Model] < ActiveRecord::Base
   has_ancestry
end
```

You can then attach a node to a parent with ordinary attributes, passed into create or new:

```ruby
TreeNode.create! :name => 'Stinky', :parent => TreeNode.create!(:name => 'Squeeky')
```

Creating a node to make it the parent of another, in one statement, is the sort of small ergonomic choice that says this gem has been used in real applications for a long time.

## Defaults you can set once, or per model

There is an initializer file for project-wide defaults:

```ruby
# config/initializers/ancestry.rb (optional)

# use the newer format
Ancestry.default_ancestry_format = :materialized_path3
# Ancestry.default_update_strategy = :sql
# Ancestry.primary_key_format = :uuid
```

Note that only the format line is uncommented, and that the initializer itself is marked optional. The other two, a SQL update strategy and a UUID primary key format, are commented out in the README, which means they are options the project supports and documents rather than defaults you get. The same settings can be passed to each `has_ancestry` call instead, so nothing here is global-only.

The primary key format option is worth pausing on, because it interacts with the materialized path design. If your keys are UUIDs rather than integers, the paths get longer, which pushes harder against column length limits and makes the format choice matter more. This is the part of the gem where a decision made at the application level, years earlier, shows up in a library configuration.

The update strategy option is the other half of the performance story. Writing a subtree involves many path rewrites, and a bulk SQL update versus per-record saves is exactly the kind of choice where correctness and speed pull apart. The gem exposes it; which one is right depends on your callbacks and validations.

For the full option list, the README's `has_ancestry` section covers `:ancestry_column` and the rest in a table, and CONFIGURATION.md is the file to read for integrity checking and restoration.

## Real associations now sit on top of the string API

The README marks one feature as new: real ActiveRecord associations, a belongs_to :parent and a has_many :children, with eager loading support. That is a meaningful change for an established gem, because the older API exposes parent_id and child_ids as plain identifiers rather than as association objects. Being able to eager load parent and children changes how the gem composes with the rest of your model layer.

It also introduces a question worth asking of any gem that adds associations over an older interface: what happens when both are used. The README does not answer that in the portion a first read covers, so if you are upgrading an existing application that already uses parent_id or child_ids, the CONFIGURATION.md file and the CHANGELOG.md are where to look before you rely on the new behavior.

The navigation table is the reference part of the README and it is well built. Every method comes in a small set: parent, ancestors, path, root on the way up; children, descendants, subtree, leaves on the way down; siblings and indirects across. Each has an id variant, a predicate such as has_children? or is_leaf?, and a from-the-other-side question such as parent_of? or in_subtree_of?. subtree is the one to remember: it is self plus descendants, which is what you want when you are about to delete or move a branch.

One footnote in that table is easy to miss and easy to get wrong: root nodes are siblings of each other. So a sibling query at the top level returns every other root, not an empty set.

## Master may describe what you cannot install yet

The README opens with a warning that is worth reading before anything else, and it is unusual to see stated so plainly. Reading the docs on GitHub means reading the master branch, which reflects bleeding-edge development and may describe features not yet released. The instruction is to use the branch selector to switch to the tag matching the version of the gem you have installed.

That warning interacts with two facts about this repository that are worth putting side by side. GitHub reports no published releases for the project at all, so there is no release feed to cross-check against. And the repository's own versioning lives in CHANGELOG.md plus the git tags.

The practical consequence: when a feature list on master says New, as it does for the associations described above, you cannot assume that feature is in the version your Gemfile.lock pins. The check that settles it is to read the CHANGELOG.md entry for your locked version, or to open the tag for that version and read the README there. If you are starting a new project, the reverse question applies and it is easier: pin a released version, then read that tag's README.

The repository is not archived and the last push was on 2026-06-25, so this is a project in active work rather than a maintained snapshot. The tree is conventional and well organized for a mature gem: lib/ for the code, test/ for the suite, gemfiles/ for Appraisal-managed dependency matrices, bin/ for generators, Appraisals for the matrix definition, a .rubocop.yml, a Rakefile, an ancestry.gemspec, a separate MIT-LICENSE file matching the MIT license GitHub reports, and CODE_OF_CONDUCT.md alongside a CI badge for a test suite workflow. There is also an img/ directory, which is what backs the navigation table's diagrams.

## Conclusion

Ancestry's bargain is unusually clear: the entire hierarchy lives in one indexed string column on your existing table, so reading a subtree is a prefix match and there is no join table to keep consistent. That is why descendants, ancestors and leaves are all single-query operations, and why the gem has stayed small and stable for years while adding real associations on top of the original string API. What you give up is equally clear, and the README is direct about the moving part: the depth of your tree becomes a column length problem, and every format in the gem trades column width against query cost in a documented way. GitHub reports no published releases, so the CHANGELOG.md in the repository and the gem version you have installed are your only version references, and the README's own warning about master describing unreleased features is worth heeding before you read a feature list as a promise. For a bounded hierarchy such as categories, file folders or org units, start with the default format, add cached columns only after a query profile shows you need them, and read CONFIGURATION.md, which the README defers to for integrity checking and restoration.

## FAQ

### What is the materialized path pattern and why does ancestry use it?

Each record stores its full ancestor chain in a single column, written with a trailing separator such as 1/2/3/. Ancestors are read by splitting that string, descendants by an indexed prefix match, and roots by taking the first segment, so no join table is involved. The trade is on writes, since moving a node rewrites the prefix on every node beneath it.

### How do I add ancestry to an ActiveRecord model?

Add the gem to your Gemfile, run the generator with rails g migration add_ancestry_to_[table], and call t.ancestry inside change_table in the generated migration. The helper creates the column with the correct type, collation and indexes, which matters because materialized path queries depend on collation. Then add has_ancestry to the model class.

### What is the difference between subtree and descendants in ancestry?

descendants returns everything below a node, and subtree returns the node itself plus its descendants. The README navigation table makes this explicit: self plus descendants for subtree against all below for descendants. Use subtree when you mean to act on the branch including its root, such as before a move or a delete.

### How deep can an ancestry tree get before the column runs out of room?

The depth limit comes from the materialized path column, since each level adds a segment. That is what the ancestry format options and the default_ancestry_format setting address, with :materialized_path3 described in the README as the newer format, and what the UUID primary key format option interacts with. CONFIGURATION.md is the file the README points to for advanced column setup and integrity checking.

## Sources

- [Issues](https://github.com/stefankroes/ancestry/issues)
- [License: MIT](https://github.com/stefankroes/ancestry/blob/master/LICENSE)
- [README](https://github.com/stefankroes/ancestry/blob/master/README.md)
- [stefankroes/ancestry on GitHub](https://github.com/stefankroes/ancestry)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/stefankroes-ancestry
