dbresolver: read/write splitting and replicas for GORM
Multiple databases, read-write splitting FOR GORM
At a glance
- What is it?
- DBResolver is a GORM plugin that gives one GORM handle a set of write sources and a set of read replicas, and then decides which one a query goes to. It switches automatically based on the table or struct being worked on, it can be overridden per query with a clause, it applies the same rule to raw SQL by looking at the statement, and it pins the connection for the duration of a transaction.
- Who is it for?
- dbresolver suits a Go application whose database is already replicated and whose read traffic dominates, and where routing reads to a replica is worth the added configuration. It does not suit a project with a single database, where the plugin only adds indirection, and it does not suit a workload that cannot tolerate replication lag on the reads it sends to a replica.
- 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 86 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 24, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One GORM handle, several databases
DBResolver adds multiple databases support to GORM, and the feature list is short enough to read as a specification.
Multiple sources and replicas. Read/write splitting. Automatic connection switching based on the working table or struct. Manual connection switching. Sources and replicas load balancing. It works for raw SQL. And it works with transactions.
Those last two matter more than they sound. A plugin that only handles the ORM's own query builder would leave raw SQL on the default connection, which in practice means every report query and every hand-written join bypasses the replication you paid for. And a plugin that switched connections mid-transaction would be worse than useless, since a transaction that reads from one database and writes to another is not a transaction.
The mechanics are a plugin registration. You open GORM as usual and then call DB.Use with a resolver configuration, and the plugin takes over the connection selection for the models or tables you name.
The package is MIT licensed and the module path is gorm.io/plugin/dbresolver, so it installs the same way as any GORM plugin. It depends on gorm.io/gorm v1.26.0 and gorm.io/driver/mysql v1.5.7 for its own tests, and it requires Go 1.18 or newer. The documentation lives with GORM itself at gorm.io/docs/dbresolver.html.
The repository is a single Go package with the files you would expect for a GORM plugin: callbacks.go for the hooks that intercept the operations, clauses.go for the query clauses, database.go, dbresolver.go, logger.go, policy.go for the load balancing policy and resolver.go for the resolution logic. There are no GitHub releases, and the last push to the repository was on 2026-07-31.
Resolvers are registered per table or per struct
The registration model is the part to understand first, because it is what lets one application talk to several database tiers at once.
The quick start registers a resolver with sources and replicas and a load balancing policy, then registers further resolvers with model arguments, and the arguments are what scope each resolver.
DB.Use(dbresolver.Register(dbresolver.Config{
// use `db2` as sources, `db3`, `db4` as replicas
Sources: []gorm.Dialector{mysql.Open("db2_dsn")},
Replicas: []gorm.Dialector{mysql.Open("db3_dsn"), mysql.Open("db4_dsn")},
// sources/replicas load balancing policy
Policy: dbresolver.RandomPolicy{},
// print sources/replicas mode in logger
ResolverModeReplica: true,
}).Register(dbresolver.Config{
// use `db1` as sources (DB's default connection), `db5` as replicas for `User`, `Address`
Replicas: []gorm.Dialector{mysql.Open("db5_dsn")},
}, &User{}, &Address{}).Register(dbresolver.Config{
// use `db6`, `db7` as sources, `db8` as replicas for `orders`, `Product`
Sources: []gorm.Dialector{mysql.Open("db6_dsn"), mysql.Open("db7_dsn")},
Replicas: []gorm.Dialector{mysql.Open("db8_dsn")},
}, "orders", &Product{}, "secondary"))Three things are visible in that snippet. The first resolver has no model arguments, so it is the global one, and it applies to anything not claimed by a more specific resolver. The second names the User and Address structs, so those two models read from the db5 replica and write to the default connection. The third names a table name as a string, orders, alongside the Product struct, and gives the resolver the name secondary, which is the handle you use to select it manually later.
So the scoping is per model or per table, and a table can be named either as a string or as a struct pointer. That flexibility is what lets a resolver be written against a table that has no Go model, which happens when the application writes SQL against a table that another service owns.
The load balancing policy and the ResolverModeReplica flag are the two other things to notice. The policy decides which of the sources or replicas a connection goes to, and the flag makes the logger print which mode was used, which is the setting you want while you are checking that the split is doing what you think.
Automatic switching, and how raw SQL is classified
The automatic behaviour is what makes the plugin useful without any changes to your application code.
DBResolver will automatically switch connections based on the working table or struct, and the README walks through the cases. A query on the users table goes to the db5 replica, a model query on User goes to the same replica, an exec of an update statement goes to the source db1, a raw select goes to the replica, and creates and deletes go to the source. For the global resolver, a find on the Pet model goes to the replicas db3 and db4 while a save goes to the source db2.
The raw SQL case is where the design has to make a judgement, and the rule is stated: for raw SQL, DBResolver extracts the table name from the SQL to match the resolver, and will use sources unless the SQL begins with SELECT.
So the read/write split for raw statements is a prefix test, and the table is parsed out of the statement to find the right resolver. That is a heuristic, and the README is honest about the mechanism rather than pretending to parse SQL properly. A statement that begins with SELECT is treated as a read, everything else as a write.
The examples show what that means in practice. An exec of an update statement goes to the sources, and a raw select with a row scan goes to the replicas. A caller who wants different behaviour has the manual clauses, which is the next section.
The Read/Write Splitting section describes the same thing at the callback level: for the Query and Row callbacks it will use replicas unless Write mode is specified, and for the Raw callback, statements are considered read-only and will use replicas if the SQL starts with SELECT.
Manual clauses for the cases the automatic rule gets wrong
The manual API is three clauses, and it exists because the automatic rule is a heuristic.
// Use Write Mode: read user from sources `db1`
DB.Clauses(dbresolver.Write).First(&user)
// Specify Resolver: read user from `secondary`'s replicas: db8
DB.Clauses(dbresolver.Use("secondary")).First(&user)
// Specify Resolver and Write Mode: read user from `secondary`'s sources: db6 or db7
DB.Clauses(dbresolver.Use("secondary"), dbresolver.Write).First(&user)The Write clause forces the sources. The Use clause with a resolver name selects that resolver rather than the one that matches the model, which is how you reach the secondary resolver that was registered under that name. And the two combined give you a specific resolver's sources.
The interesting combination is the third one, because it is the one that resolves a real problem. If a table is served by the secondary resolver and you need to read your own uncommitted or just-written data, the Write clause sends the read to the sources instead of the replicas, and you get the current value rather than whatever the replica has caught up to.
That is the pattern you will use with read/write splitting in practice. The automatic mode is right most of the time, and the Write clause is the escape hatch for the query that has to see the latest state.
The clauses are added to the query the same way any other GORM clause is, so they compose with the rest of GORM's API rather than requiring a separate entry point.
Transactions pin the connection on purpose
The transaction behaviour is a deliberate restriction rather than a limitation, and the README states it plainly.
When using a transaction, DBResolver will keep using the transaction and won't switch to sources or replicas based on configuration. That is the only correct behaviour: a transaction that read from a replica and wrote to a source would not be a transaction, and GORM's transaction scope is a promise that the work is isolated.
But you can specify which database to use before starting the transaction, and the three clauses work here too.
// Start transaction based on default replicas db
tx := DB.Clauses(dbresolver.Read).Begin()
// Start transaction based on default sources db
tx := DB.Clauses(dbresolver.Write).Begin()
// Start transaction based on `secondary`'s sources
tx := DB.Clauses(dbresolver.Use("secondary"), dbresolver.Write).Begin()So a transaction that should read from the replicas is started with the Read clause, one that must see the latest data is started with the Write clause, and one that belongs to a specific resolver combines the two.
The read clause for a transaction is the case worth thinking about. A long report transaction that only reads can be pointed at a replica without affecting its consistency, since it reads one consistent snapshot throughout. That is a meaningful saving on a reporting workload, and it is available only because the clause is evaluated before the transaction begins.
Once the transaction is running, the configuration is ignored, which means a resolver registered later does not move an open transaction to another database.
One load balancing policy, and the pool settings on the same call
The load balancing policy is a one method interface, which makes it easy to write your own.
type Policy interface {
Resolve([]gorm.ConnPool) gorm.ConnPool
}The implementation takes the pool of candidate connections and returns the one to use. Currently only the RandomPolicy is implemented, and it is the default option if no policy is specified.
A single implementation with a small interface is a deliberate design choice. The obvious alternatives, round robin or least connections, are both implementable by anyone who needs them, and the plugin does not force a policy on you. What it does not do is decide between policies for you, so if you want the least loaded replica you write the Resolve method yourself.
The connection pool settings are on the same call as the registration, which is convenient because each resolver has its own pools and therefore its own limits.
DB.Use(
dbresolver.Register(dbresolver.Config{ /* xxx */ }).
SetConnMaxIdleTime(time.Hour).
SetConnMaxLifetime(24 * time.Hour).
SetMaxIdleConns(100).
SetMaxOpenConns(200)
)That matters in practice. If a resolver points at two replicas and a third at one source, the pools are separate, and a pool of 200 connections to a replica that only handles read traffic is a different resource from the source pool that carries the writes. Setting them per resolver is what lets you size a replica pool larger than a source pool without reconfiguring GORM's default pool.
The repository's own test setup follows the same idea, with a docker-compose file that starts four MySQL servers on ports 9911 to 9914, each with the gorm database and user, and a random root password, because the tests exercise the switching behaviour against real servers rather than mocks.
Where dbresolver is the wrong choice
There are cases where a connection splitting plugin is the wrong answer, and the first one is simply having one database.
If you run a single MySQL instance, every line of configuration above is overhead. There is no replica to read from, no split to make, and the plugin adds a layer between your query and the connection for no benefit.
The second limit is the one every read/write split has, and the plugin cannot solve it. Replication is asynchronous, so a write that has been committed on the source may not be on the replica yet. A read that goes to the replica immediately after a write can return the old value. That is why the Write clause exists, and why the Read clause exists for transactions. If your application reads its own writes without adding one of those clauses, this plugin will introduce a bug rather than fix one.
The third is the raw SQL heuristic. A statement that begins with SELECT is treated as a read, so a SELECT with a locking clause, or a SELECT inside a write transaction on the source, is routed to a replica where it does not belong. The manual clauses fix it for one query at a time, which is fine for the handful of queries that need it and tedious for a codebase that writes a lot of raw SQL.
The fourth is the routing granularity. The plugin chooses between sources and replicas, and within a group the policy chooses. It cannot send a specific query to a specific replica, so a workload that needs to pin a session to one server for a consistency reason is not served by this plugin.
The alternative approach is to put a proxy in front of the database, which is how read/write splitting is often done at the infrastructure layer, with the application connecting to one endpoint. The difference in approach is that the proxy splits traffic for every client of that endpoint including the tools you did not write, while the plugin splits only the queries of your GORM application and leaves everything else, including the reporting tool that runs raw SQL, on the source. The proxy is a component to run and to keep alive; the plugin is a dependency and a set of clauses.
Editorial conclusion
dbresolver suits a Go application whose database is already replicated and whose read traffic dominates, and where routing reads to a replica is worth the added configuration. It does not suit a project with a single database, where the plugin only adds indirection, and it does not suit a workload that cannot tolerate replication lag on the reads it sends to a replica. Check three things before you rely on it: how your database replicates and how stale a replica can be, since a read that follows a write on the source can return the old value, which is the classic failure of read/write splitting, whether your raw SQL statements are all obviously readable, because the rule for raw SQL is that a statement starting with SELECT goes to a replica, and whether the GORM version you use matches the plugin, since the plugin depends on gorm.io/gorm v1.26.0 and Go 1.18 or newer. The last push to the repository was on 2026-07-31 and the project publishes no GitHub releases.
Frequently asked questions
What does the dbresolver plugin add to GORM?
It adds multiple sources and replicas, read/write splitting, automatic connection switching based on the working table or struct, manual connection switching, sources and replicas load balancing, support for raw SQL and support for transactions. The module path is gorm.io/plugin/dbresolver and it requires Go 1.18 or newer.
How do I register a resolver for specific models in dbresolver?
Pass the models or table names as arguments to Register, for example a resolver with &User{} and &Address{} applies to those two models, and a resolver with "orders" and &Product{} applies to that table and model. A resolver registered with no model arguments is the global one used by anything not claimed by a more specific resolver.
How does dbresolver decide between replicas and sources for raw SQL?
It extracts the table name from the SQL to match the resolver, and will use sources unless the SQL begins with SELECT. For the Query and Row callbacks it uses replicas unless Write mode is specified, and for the Raw callback statements are considered read-only and use replicas if the SQL starts with SELECT.
How do transactions behave with dbresolver?
DBResolver keeps using the transaction and will not switch to sources or replicas based on configuration. You can choose the database before starting the transaction with DB.Clauses(dbresolver.Read).Begin(), DB.Clauses(dbresolver.Write).Begin(), or by combining the Write clause with dbresolver.Use for a specific resolver.
Which load balancing policy does dbresolver use?
The policy is an interface with a Resolve method that takes the candidate connection pools and returns one, and currently only RandomPolicy is implemented, which is also the default if no policy is specified. The connection pool settings are set on the same call, with SetConnMaxIdleTime, SetConnMaxLifetime, SetMaxIdleConns and SetMaxOpenConns.
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/go-gorm-dbresolver)Was this useful?