# F(x): versioned SQL functions and triggers for Rails migrations

> F(x) adds methods to ActiveRecord::Migration so you can create, version, update and drop database functions and triggers without abandoning schema.rb for raw SQL. Definitions live in .sql files next to your migrations, which gives you editor syntax highlighting and a version history that stays reversible, with PostgreSQL support built in and a small adapter interface for other engines.

**teoljungberg/fx** — Versioned database functions and triggers for Rails

- Repository: https://github.com/teoljungberg/fx
- Stars: 867 · Forks: 82
- Language: Ruby
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/teoljungberg-fx

## What F(x) adds and why it exists

F(x) adds methods to ActiveRecord::Migration to create and manage database functions and triggers in Rails. The README's framing of the problem is precise: it lets you bring the power of SQL functions and triggers to a Rails application without having to switch your schema format to SQL.

That last clause is the design constraint everything else follows from. The standard Rails answer to database functions is to move to structure.sql, which is a significant change to how the project works, loses the Ruby readability of schema.rb, and interacts with tooling in ways that are awkward to maintain. F(x) takes the other route and makes the Ruby schema format work with functions and triggers included.

The three benefits the README claims are worth separating. It provides a convention for versioning functions and triggers that keeps the migration history consistent and reversible. It avoids having to duplicate SQL strings across migrations, which is the problem you hit the moment a function body changes and you have to find every migration that mentions it. And as an added bonus, you define the structure of your function in a SQL file, which means full SQL syntax highlighting in the editor of your choice and the ability to test your SQL directly in the database console during development.

That last point is the one developers tend to underrate. A function body written in a .sql file is something you can paste into psql and run in a second, without going through a migration and a rollback. Writing it inside a Ruby string means you cannot.

The shipped support is for PostgreSQL, and the adapter is configurable through Fx::Configuration with a minimal interface that other gems can provide, which is how the community adapters exist.

## Creating a function and a trigger

The workflow starts with a generator. You decide on a name, here uppercase_users_name, and run:

```sh
% rails generate fx:function uppercase_users_name
      create  db/functions/uppercase_users_name_v01.sql
      create  db/migrate/[TIMESTAMP]_create_function_uppercase_users_name.rb
```

Two files appear. The definition file holds the SQL for the function, version 1 by convention, and the migration calls create_function.

The trigger generator takes the name and the table it should fire on:

```sh
% rails generate fx:trigger uppercase_users_name table_name:users
      create  db/triggers/uppercase_users_name_v01.sql
      create  db/migrate/[TIMESTAMP]_create_trigger_uppercase_users_name.rb
```

And the trigger definition is ordinary SQL:

```sql
CREATE TRIGGER uppercase_users_name
    BEFORE INSERT ON users
    FOR EACH ROW
    EXECUTE FUNCTION uppercase_users_name();
```

The generated migrations contain create_function and create_trigger statements. The README notes the migration is reversible and that the schema will be dumped into your schema.rb file, which is the whole point: after migrating, your schema.rb contains the function and trigger definitions alongside the tables, and the project can be rebuilt from it.

The migration is then run as usual:

```sh
% rake db:migrate
```

What is worth noticing about the naming is the version suffix on the definition file, uppercase_users_name_v01.sql, and the absence of one on the migration. The version belongs to the definition, not to the migration, and the migration records which version it moves to. That is the distinction that makes the update flow work.

## Changing a function, and why the version convention earns its keep

This is where the README says F(x) really shines, and the mechanism is simple enough to explain in one paragraph.

You run the same function generator again:

```sh
% rails generate fx:function uppercase_users_name
      create  db/functions/uppercase_users_name_v02.sql
      create  db/migrate/[TIMESTAMP]_update_function_uppercase_users_name_to_version_2.rb
```

F(x) detected that a function named uppercase_users_name already existed at version 1, created a copy of that definition as version 2, and created a migration to update to the version 2 schema. All that is left is to tweak the SQL in the new definition file and run the update_function migration.

That is the whole design, and it is a good one. The generator does not need to understand anything about your function. It looks up the current version by name, copies the file forward with the next version number, and writes a migration that replaces the function.

The consequences are what make this worth having. A function's history is a sequence of numbered .sql files in version order, reviewable in a pull request like any other code. Each migration contains a create or update for one named object. Nothing is duplicated across migrations, because each version is a full copy rather than a diff, and the diff lives in the version control history of the .sql file.

The copy-forward approach also means the generator never has to parse SQL. It copies bytes. That means a function body can contain anything PostgreSQL accepts, including dynamic SQL, dollar quoting and plpgsql blocks, without the gem needing to understand it.

The naming of the migration is also informative: update_function_uppercase_users_name_to_version_2.rb, which says what it does and what it moves to. A migration list in development tells you the shape of your schema history without opening the files.

## Dropping objects, and using a function as a column default

Two smaller features round out the API.

The first is removal. F(x) gives you drop_trigger and drop_function, and the README shows a reversible drop:

```ruby
def change
  drop_function :uppercase_users_name, revert_to_version: 2
end
```

The revert_to_version option is what keeps the removal reversible in the same way the creation was. Dropping a function that had three versions and rolling back leaves you with version 2 rather than with nothing, which is the behaviour you want from a migration framework.

The second is using a function as a column default. Rails will normally treat a default as a literal string, so a function call in a default has to be marked as literal SQL. You enable that in an initializer:

```ruby
# config/initializers/fx.rb
Fx.configure do |config|
  config.dump_functions_at_beginning_of_schema = true
end
```

And then in the migration you use a lambda:

```ruby
create_table :my_table do |t|
  t.string :my_column, default: -> { "my_function()" }
end
```

The lambda tells Rails to use the default as a literal SQL for the default column value instead of a plain string. The dump_functions_at_beginning_of_schema setting controls where the function definitions land in schema.rb, at the beginning rather than the end, which matters because a function used as a default has to exist before the table that references it is created.

That ordering constraint is the sort of detail that quietly causes failures when schema.rb is loaded into a fresh database, and having the gem handle the position is the difference between a schema file that loads and one that does not.

## Controlling the schema dump order

By default, functions and triggers are dumped to schema.rb in the order the database returned them. The README explains how to change that, and the mechanism is a documented extension point rather than a config option.

If you need a specific ordering, for example alphabetical for deterministic diffs, you subclass the adapter and override the functions or triggers methods:

```ruby
# config/initializers/fx.rb
class SortedPostgresAdapter < Fx::Adapters::Postgres
  def functions
    super.sort_by(&:name)
  end

  def triggers
    super.sort_by(&:name)
  end
end

Fx.configure do |config|
  config.database = SortedPostgresAdapter.new
end
```

The README makes a commitment about this API that is worth noting: these methods are part of the adapter's public API and will remain stable across releases. That is an explicit stability promise about the extension point, which is what you need in order to depend on subclassing it.

The same approach covers harder cases, and the README gives one. If your functions depend on each other and need to be dumped in dependency order, you can use Ruby's built-in TSort to topologically sort them.

The underlying problem this solves is a real one. A schema file whose function order changes between dumps produces a diff on every migration, even when nothing changed. Alphabetical ordering makes the dump deterministic, and dependency ordering makes it loadable. Both are achieved by overriding the same two methods.

## Adapters, supported versions and how the project is maintained

The adapter interface is deliberately small, and the README points at three community gems that implement it: a MySQL adapter, an Oracle adapter and a SQL Server adapter.

The design consequence is that F(x) itself is not tied to one database. The core is the versioning convention, the generators and the migration methods, and the database-specific work is behind the adapter. The README mentions Fx::Configuration for the adapter setting and Fx::Adapters::Postgres as the reference implementation with a minimal interface that other gems can provide.

That is a good separation, and it also means the community adapters are not first-party code. They carry their own maintenance status, and the README does not claim otherwise. Anyone running MySQL is depending on a separate gem for the parts that actually touch the database.

The version support policy follows the maintenance policies of Ruby, Rails and PostgreSQL, supporting versions within their official maintenance windows. The supported set is Ruby 3.3 and newer, Rails 7.2, 8.0 and 8.1, and PostgreSQL 14 through 18. When a version reaches end of life, support is dropped in the next minor release of F(x), and older versions may continue to work but are not tested or guaranteed.

That policy is a good sign for a library whose whole purpose is to sit underneath your migrations. It means the gem's support window and your database's support window move together.

The repository itself is a gem with a gemspec, a Rakefile, a lib/ directory, a spec/ directory with a .rspec file, a Gemfile, and a .standard.yml for the standard Ruby linter. The CI workflow badge is for the GitHub Actions ci.yml workflow, and there is a CHANGELOG.md and CONTRIBUTING.md.

The release history shows active work: v0.11.0 in May 2026, then v0.10.2 and v0.10.1 both on the same day in March, which suggests a maintenance release pushed for a specific fix. The last push was on 2026-09-02.

## Limits: PostgreSQL-shaped, schema.rb-bound, and version 0.x

The limits are worth stating plainly.

The first is that the shipped adapter is PostgreSQL. The SQL examples use BEFORE INSERT, FOR EACH ROW and EXECUTE FUNCTION, which is PostgreSQL syntax, and the other engines work through community gems that are not part of this project. The versioning convention is engine-agnostic, but everything about how a function is replaced, how a trigger is dropped and how the schema is dumped is written for one database first.

The second is the dependency on schema.rb. The entire value proposition is that you keep the Ruby schema format, so an application that has already migrated to structure.sql has no use for this. And the gem's contribution to schema.rb, the function and trigger definitions, has to merge with whatever your own schema.rb contains.

The third is that the dump order problem is not fully solved by default. Out of the box the order is whatever the database returns, and getting deterministic output means subclassing the adapter. That is a reasonable design, since one adapter configuration cannot know your dependency graph, but it does mean the polished setup requires an initializer file that most projects will not write until they hit a confusing diff.

The fourth is the version number. It is 0.11.0, and in Ruby a 0.x version carries no stability promise, so the public API can change in a minor release. The README does commit to the functions and triggers methods being stable, which is the extension point that matters most for the subclassing pattern above, but the broader surface is not covered by that promise.

The fifth is the copy-forward versioning model. Every new version is a full copy of the previous .sql file, so the file for version 5 contains the entire function body, not a delta. That is what makes the generator's job trivial and the SQL editable in isolation, and it is also why the diff history lives in the file's own version control rather than in the migration.

The sixth is the scope of what is versioned. Only functions and triggers are handled. Indexes, views, materialized views, extensions and other schema objects that live outside the table definitions are not mentioned, and they remain your problem.

## F(x) against raw SQL migrations and against a schema.rb migration by hand

Two comparisons make the choice clearer.

The first is against writing the SQL by hand in a migration, which is what most projects do before they find F(x). The difference in approach is the schema dump. When you create a function with execute in a migration, the function does not appear in schema.rb, so a database rebuilt from the schema file has the tables and not the function, and the application breaks in a way that only shows up on a fresh deploy or in staging. The usual fix is to switch to structure.sql, and F(x) exists to avoid making that choice.

So the value is not that writing the SQL is hard. It is that the function and the trigger become part of the schema the application claims to have, and that the migration history stays in Ruby.

The second comparison is against writing the function definitions into schema.rb by hand, which is what some teams do for a handful of functions. The difference is versioning. Hand-editing schema.rb means the schema file is edited outside the migration history, so the file and the migrations can disagree, and there is no record of what the function looked like when it was introduced. F(x) keeps the definition files in version order next to the migrations and lets a generator produce the migration for you, which is the same discipline Rails applies to columns and indexes.

That last point is the strongest argument for the gem. Rails already solved the problem of schema drift for tables by moving the schema definition into versioned migrations. F(x) applies the same idea to the objects Rails does not model, and the people most likely to want it are the ones who have been annoyed that Rails does not handle functions at all.

## Conclusion

Adopt F(x) when a Rails application needs database functions or triggers and you want them versioned alongside the rest of your schema, in a form that stays reversible. Do not adopt it if your project has moved off schema.rb entirely, since the whole model depends on that file, and check the supported version matrix before upgrading. Verify first that your database engine is PostgreSQL or that a community adapter exists for it, that your Rails version falls inside a supported window, and that your existing schema.rb is dumped in a form the gem can add to without a manual merge.

## FAQ

### What does the F(x) gem add to Rails?

F(x) adds methods to ActiveRecord::Migration for creating and managing database functions and triggers, so you can use SQL functions and triggers without switching your schema format to SQL. It provides a convention for versioning functions and triggers that keeps the migration history consistent and reversible, and stores definitions in .sql files for editor syntax highlighting and console testing.

### How do I change a database function that F(x) created?

Run the same generator again with the same name. F(x) detects that the function already exists at a previous version, copies the definition file forward as the next version, and creates an update_function migration. You then edit the SQL in the new version file and run the migration.

### Which databases and versions does F(x) support?

F(x) ships with PostgreSQL support through Fx::Adapters::Postgres, and the adapter is configurable, with community gems providing MySQL, Oracle and SQL Server adapters. The supported versions follow the maintenance policies of the underlying tools: Ruby 3.3 and newer, Rails 7.2, 8.0 and 8.1, and PostgreSQL 14 through 18.

## Sources

- [Issues](https://github.com/teoljungberg/fx/issues)
- [License: MIT](https://github.com/teoljungberg/fx/blob/master/LICENSE)
- [README](https://github.com/teoljungberg/fx/blob/master/README.md)
- [Releases](https://github.com/teoljungberg/fx/releases)
- [teoljungberg/fx on GitHub](https://github.com/teoljungberg/fx)

---

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