Advanced queries in Dart

Use sql joins or custom expressions from the Dart api


Drift supports sql joins to write queries that operate on more than one table. To use that feature, start a select regular select statement with select(table) and then add a list of joins using .join(). For inner and left outer joins, a ON expression needs to be specified. Here's an example using the tables defined in the example.

// We define a data class to contain both a todo entry and the associated
// category.
class EntryWithCategory {
  EntryWithCategory(this.entry, this.category);

  final Todo entry;
  final Category? category;

  // in the database class, we can then load the category for each entry
  Stream<List<EntryWithCategory>> entriesWithCategory() {
    final query = select(todos).join([

    // see next section on how to parse the result

Of course, you can also join multiple tables:

/// Searches for todo entries in the same category as the ones having
/// `titleQuery` in their titles.
Future<List<Todo>> otherTodosInSameCategory(String titleQuery) async {
  // Since we're adding the same table twice (once to filter for the title,
  // and once to find other todos in same category), we need a way to
  // distinguish the two tables. So, we're giving one of them a special name:
  final otherTodos = alias(todos, 'inCategory');

  final query = select(otherTodos).join([
    // In joins, `useColumns: false` tells drift to not add columns of the
    // joined table to the result set. This is useful here, since we only join
    // the tables so that we can refer to them in the where clause.
        useColumns: false),
    innerJoin(todos, todos.category.equalsExp(,
        useColumns: false),

  return => row.readTable(otherTodos)).get();

Parsing results

Calling get() or watch on a select statement with join returns a Future or Stream of List<TypedResult>, respectively. Each TypedResult represents a row from which data can be read. It contains a rawData getter to obtain the raw columns. But more importantly, the readTable method can be used to read a data class from a table.

In the example query above, we can read the todo entry and the category from each row like this:

return {
  return {
    return EntryWithCategory(

Note: readTable will throw an ArgumentError when a table is not present in the row. For instance, todo entries might not be in any category. To account for that, we use row.readTableOrNull to load categories.

Custom columns

Select statements aren't limited to columns from tables. You can also include more complex expressions in the query. For each row in the result, those expressions will be evaluated by the database engine.

class EntryWithImportance {
  final TodoEntry entry;
  final bool important;

  EntryWithImportance(this.entry, this.important);

Future<List<EntryWithImportance>> loadEntries() {
  // assume that an entry is important if it has the string "important" somewhere in its content
  final isImportant ='%important%');

  return select(todos).addColumns([isImportant]).map((row) {
    final entry = row.readTable(todos);
    final entryIsImportant =;

    return EntryWithImportance(entry, entryIsImportant);

Note that the like check is not performed in Dart - it's sent to the underlying database engine which can efficiently compute it for all rows.


Sometimes, a query references a table more than once. Consider the following example to store saved routes for a navigation system:

class GeoPoints extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
  TextColumn get latitude => text()();
  TextColumn get longitude => text()();

class Routes extends Table {

  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();

  // contains the id for the start and destination geopoint.
  IntColumn get start => integer()();
  IntColumn get destination => integer()();

Now, let's say we wanted to also load the start and destination GeoPoint object for each route. We'd have to use a join on the geo-points table twice: For the start and destination point. To express that in a query, aliases can be used:

class RouteWithPoints {
  final Route route;
  final GeoPoint start;
  final GeoPoint destination;

  RouteWithPoints({this.route, this.start, this.destination});

// inside the database class:
Future<List<RouteWithPoints>> loadRoutes() async {
  // create aliases for the geoPoints table so that we can reference it twice
  final start = alias(geoPoints, 's');
  final destination = alias(geoPoints, 'd');

  final rows = await select(routes).join([

  return {
    return RouteWithPoints(
      route: resultRow.readTable(routes),
      start: resultRow.readTable(start),
      destination: resultRow.readTable(destination),

The generated statement then looks like this:

SELECT,, routes.start, routes.destination,,, s.latitude, s.longitude,,, d.latitude, d.longitude
FROM routes
    INNER JOIN geo_points s ON = routes.start
    INNER JOIN geo_points d ON = routes.destination

ORDER BY and WHERE on joins

Similar to queries on a single table, orderBy and where can be used on joins too. The initial example from above is expanded to only include todo entries with a specified filter and to order results based on the category's id:

Stream<List<EntryWithCategory>> entriesWithCategory(String entryFilter) {
  final query = select(todos).join([
  // ...

As a join can have more than one table, all tables in where and orderBy have to be specified directly (unlike the callback on single-table queries that gets called with the right table by default).

Group by

Sometimes, you need to run queries that aggregate data, meaning that data you're interested in comes from multiple rows. Common questions include

  • how many todo entries are in each category?
  • how many entries did a user complete each month?
  • what's the average length of a todo entry?

What these queries have in common is that data from multiple rows needs to be combined into a single row. In sql, this can be achieved with "aggregate functions", for which drift has builtin support.

Additional info: A good tutorial for group by in sql is available here.

To write a query that answers the first question for us, we can use the count function. We're going to select all categories and join each todo entry for each category. What's special is that we set useColumns: false on the join. We do that because we're not interested in the columns of the todo item. We only care about how many there are. By default, drift would attempt to read each todo item when it appears in a join.

Future<void> countTodosInCategories() async {
  final amountOfTodos =;

  final query = select(categories).join([
      useColumns: false,

  final result = await query.get();

  for (final row in result) {
    print('there are ${} entries in'

To find the average length of a todo entry, we use avg. In this case, we don't even have to use a join since all the data comes from a single table (todos). That's a problem though - in the join, we used useColumns: false because we weren't interested in the columns of each todo item. Here we don't care about an individual item either, but there's no join where we could set that flag. Drift provides a special method for this case - instead of using select, we use selectOnly. The "only" means that drift will only report columns we added via "addColumns". In a regular select, all columns from the table would be selected, which is what you'd usually need.

Stream<double> averageItemLength() {
  final avgLength = todos.content.length.avg();
  final query = selectOnly(todos)..addColumns([avgLength]);

  return =>!).watchSingle();