Skip to content

Generated Columns

Creating computed columns in Drift


What is it?

Generated Columns are columns whose values are computed from other columns in the same table, rather than being explicitly inserted. They can be either stored in the database (taking up space but faster to read) or virtual (computed on-the-fly when queried). Drift supports both types with a simple, type-safe API.

Think of Generated Columns like "spreadsheet formulas" – instead of manually calculating a value, you define the formula and the value is automatically computed whenever the source data changes.

class Products extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get firstName => text()();
  TextColumn get lastName => text()();

  // 👇 Generated column computing full name
  TextColumn get fullName => text()
    .generatedAs(
      // 👇 SQL expression
      const Constant("firstName || ' ' || lastName"),
      // 👇 Store in database (vs virtual)
      stored: true,
    )();
}

// When you insert:
// firstName = 'John', lastName = 'Doe'
// fullName = 'John Doe' (automatically computed)

What's happening here? - Expression – SQL formula for computing the value - Stored – Value is persisted in the database - Virtual – Value is computed when queried - Automatic – No manual calculation needed


Why does it exist?

  • Data Consistency – Always computed from source data
  • Performance – Pre-compute expensive calculations
  • Simplicity – No need to calculate in application code
  • Indexing – Generated columns can be indexed
  • Data Normalization – Derive values from normalized data
  • Business Logic – Enforce business rules at database level

Stored vs Virtual Columns

Understanding the difference between stored and virtual generated columns

Stored Generated Columns

Values are physically stored in the database

class StoredExample extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get firstName => text()();
  TextColumn get lastName => text()();

  // 👇 STORED generated column
  TextColumn get fullName => text()
    .generatedAs(
      const Constant("firstName || ' ' || lastName"),
      stored: true, // 👈 Persisted in database
    )();

  // 👇 STORED with numeric calculation
  IntColumn get age => integer()
    .generatedAs(
      const Constant("(strftime('%Y', 'now') - strftime('%Y', birthDate))"),
      stored: true,
    )();

  DateTimeColumn get birthDate => dateTime()();
}

// Generated SQL:
// CREATE TABLE stored_example (
//   id INTEGER PRIMARY KEY AUTOINCREMENT,
//   first_name TEXT NOT NULL,
//   last_name TEXT NOT NULL,
//   full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED,
//   age INTEGER GENERATED ALWAYS AS ((strftime('%Y', 'now') - strftime('%Y', birth_date))) STORED,
//   birth_date INTEGER NOT NULL
// )

Key insights: - STORED – Value takes up disk space - Faster reads – No calculation on query - Can be indexed – For faster queries - Updates automatically – When source columns change


Virtual Generated Columns

Values are computed when queried

class VirtualExample extends Table {
  IntColumn get id => integer().autoIncrement()();
  RealColumn get price => real()();
  IntColumn get quantity => integer()();

  // 👇 VIRTUAL generated column (default)
  RealColumn get total => real()
    .generatedAs(
      const Constant("price * quantity"),
      // stored defaults to false
    )();

  // 👇 VIRTUAL text column
  TextColumn get status => text()
    .generatedAs(
      const Constant("CASE WHEN quantity > 0 THEN 'in stock' ELSE 'out of stock' END"),
    )();
}

// Generated SQL:
// CREATE TABLE virtual_example (
//   id INTEGER PRIMARY KEY AUTOINCREMENT,
//   price REAL NOT NULL,
//   quantity INTEGER NOT NULL,
//   total REAL GENERATED ALWAYS AS (price * quantity) VIRTUAL,
//   status TEXT GENERATED ALWAYS AS (CASE WHEN quantity > 0 THEN 'in stock' ELSE 'out of stock' END) VIRTUAL
// )

Key insights: - VIRTUAL – No disk space used - Slower reads – Computed each time - Cannot be indexed – On virtual columns - Updates automatically – When source columns change


Common Generated Column Patterns

Typical use cases for generated columns

Pattern 1: Full Name from First and Last Name

class Users extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get firstName => text().named('first_name')();
  TextColumn get lastName => text().named('last_name')();

  // 👇 Stored full name for faster queries
  TextColumn get fullName => text()
    .generatedAs(
      const Constant("TRIM(first_name || ' ' || last_name)"),
      stored: true,
    )
    .named('full_name')();

  // 👇 Virtual initials for quick display
  TextColumn get initials => text()
    .generatedAs(
      const Constant("SUBSTR(first_name, 1, 1) || SUBSTR(last_name, 1, 1)"),
    )
    .named('initials')();
}

// Generated SQL:
// CREATE TABLE users (
//   id INTEGER PRIMARY KEY AUTOINCREMENT,
//   first_name TEXT NOT NULL,
//   last_name TEXT NOT NULL,
//   full_name TEXT GENERATED ALWAYS AS (TRIM(first_name || ' ' || last_name)) STORED,
//   initials TEXT GENERATED ALWAYS AS (SUBSTR(first_name, 1, 1) || SUBSTR(last_name, 1, 1)) VIRTUAL
// )

Pattern 2: Computed Totals

class OrderItems extends Table {
  IntColumn get id => integer().autoIncrement()();
  IntColumn get quantity => integer()();
  RealColumn get unitPrice => real().named('unit_price')();
  RealColumn get discountPercent => real()
    .withDefault(const Constant(0.0))
    .named('discount_percent')();

  // 👇 Subtotal without discount
  RealColumn get subtotal => real()
    .generatedAs(
      const Constant("quantity * unit_price"),
      stored: true,
    )();

  // 👇 Discount amount
  RealColumn get discountAmount => real()
    .generatedAs(
      const Constant("subtotal * discount_percent / 100"),
      stored: true,
    )
    .named('discount_amount')();

  // 👇 Final total with discount
  RealColumn get total => real()
    .generatedAs(
      const Constant("subtotal - discount_amount"),
      stored: true,
    )();

  // 👇 Virtual tax calculation
  RealColumn get tax => real()
    .generatedAs(
      const Constant("total * 0.1"),
    )
    .named('tax')();
}

// Generated SQL:
// CREATE TABLE order_items (
//   id INTEGER PRIMARY KEY AUTOINCREMENT,
//   quantity INTEGER NOT NULL,
//   unit_price REAL NOT NULL,
//   discount_percent REAL NOT NULL DEFAULT 0.0,
//   subtotal REAL GENERATED ALWAYS AS (quantity * unit_price) STORED,
//   discount_amount REAL GENERATED ALWAYS AS (subtotal * discount_percent / 100) STORED,
//   total REAL GENERATED ALWAYS AS (subtotal - discount_amount) STORED,
//   tax REAL GENERATED ALWAYS AS (total * 0.1) VIRTUAL
// )

Pattern 3: Conditional Status Based on Data

class InventoryItems extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
  IntColumn get stock => integer()();
  IntColumn get reorderLevel => integer()
    .withDefault(const Constant(10))
    .named('reorder_level')();

  // 👇 Status based on stock levels
  TextColumn get status => text()
    .generatedAs(
      const Constant('''
        CASE 
          WHEN stock <= 0 THEN 'out_of_stock'
          WHEN stock < reorder_level THEN 'low_stock'
          ELSE 'in_stock'
        END
      '''),
      stored: true,
    )();

  // 👇 Virtual urgency indicator
  TextColumn get urgency => text()
    .generatedAs(
      const Constant('''
        CASE 
          WHEN stock <= 0 THEN 'immediate'
          WHEN stock < reorder_level THEN 'soon'
          ELSE 'none'
        END
      '''),
    )
    .named('urgency')();

  // 👇 Stock percentage (for UI progress bars)
  RealColumn get stockPercentage => real()
    .generatedAs(
      const Constant('''
        CASE 
          WHEN reorder_level = 0 THEN 100.0
          ELSE (stock * 100.0 / (reorder_level * 2))
        END
      '''),
      stored: true,
    )
    .named('stock_percentage')
    .customConstraint('CHECK (stock_percentage >= 0)')();
}

// Generated SQL:
// CREATE TABLE inventory_items (
//   id INTEGER PRIMARY KEY AUTOINCREMENT,
//   name TEXT NOT NULL,
//   stock INTEGER NOT NULL,
//   reorder_level INTEGER NOT NULL DEFAULT 10,
//   status TEXT GENERATED ALWAYS AS (
//     CASE 
//       WHEN stock <= 0 THEN 'out_of_stock'
//       WHEN stock < reorder_level THEN 'low_stock'
//       ELSE 'in_stock'
//     END
//   ) STORED,
//   urgency TEXT GENERATED ALWAYS AS (
//     CASE 
//       WHEN stock <= 0 THEN 'immediate'
//       WHEN stock < reorder_level THEN 'soon'
//       ELSE 'none'
//     END
//   ) VIRTUAL,
//   stock_percentage REAL GENERATED ALWAYS AS (
//     CASE 
//       WHEN reorder_level = 0 THEN 100.0
//       ELSE (stock * 100.0 / (reorder_level * 2))
//     END
//   ) STORED CHECK (stock_percentage >= 0)
// )

Pattern 4: Date and Time Calculations

class Events extends Table {
  IntColumn get id => integer().autoIncrement()();
  DateTimeColumn get eventDate => dateTime().named('event_date')();
  TextColumn get eventName => text().named('event_name')();

  // 👇 Extract year from date
  IntColumn get year => integer()
    .generatedAs(
      const Constant("strftime('%Y', eventDate)"),
      stored: true,
    )();

  // 👇 Extract month name
  TextColumn get month => text()
    .generatedAs(
      const Constant("strftime('%B', eventDate)"),
      stored: true,
    )
    .named('month')();

  // 👇 Extract day of week
  TextColumn get dayOfWeek => text()
    .generatedAs(
      const Constant("strftime('%A', eventDate)"),
      stored: true,
    )
    .named('day_of_week')();

  // 👇 Is event in the future?
  BoolColumn get isUpcoming => boolean()
    .generatedAs(
      const Constant("eventDate > datetime('now')"),
    )
    .named('is_upcoming')();

  // 👇 Virtual: days until event
  IntColumn get daysUntil => integer()
    .generatedAs(
      const Constant("julianday(eventDate) - julianday('now')"),
    )
    .named('days_until')();
}

// Generated SQL:
// CREATE TABLE events (
//   id INTEGER PRIMARY KEY AUTOINCREMENT,
//   event_date INTEGER NOT NULL,
//   event_name TEXT NOT NULL,
//   year INTEGER GENERATED ALWAYS AS (strftime('%Y', event_date)) STORED,
//   month TEXT GENERATED ALWAYS AS (strftime('%B', event_date)) STORED,
//   day_of_week TEXT GENERATED ALWAYS AS (strftime('%A', event_date)) STORED,
//   is_upcoming INTEGER GENERATED ALWAYS AS (event_date > datetime('now')) VIRTUAL,
//   days_until INTEGER GENERATED ALWAYS AS (julianday(event_date) - julianday('now')) VIRTUAL
// )

Generated Columns with Indexes

Indexing generated columns for performance

class Orders extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get orderNumber => text().named('order_number')();
  RealColumn get subtotal => real()();
  RealColumn get taxPercent => real()
    .withDefault(const Constant(0.1))
    .named('tax_percent')();

  // 👇 Generated total
  RealColumn get total => real()
    .generatedAs(
      const Constant("subtotal * (1 + taxPercent)"),
      stored: true, // Must be STORED to index
    )
    .named('total')();

  // 👇 Generated order status
  TextColumn get status => text()
    .generatedAs(
      const Constant('''
        CASE 
          WHEN isPaid = 1 AND isShipped = 1 THEN 'delivered'
          WHEN isPaid = 1 AND isShipped = 0 THEN 'processing'
          WHEN isPaid = 0 THEN 'pending'
          ELSE 'unknown'
        END
      '''),
      stored: true,
    )();

  BoolColumn get isPaid => boolean()
    .withDefault(const Constant(false))
    .named('is_paid')();

  BoolColumn get isShipped => boolean()
    .withDefault(const Constant(false))
    .named('is_shipped')();

  // 👇 Indexes on generated columns
  @override
  List<Index> get indexes => [
    // ✅ Can index STORED generated columns
    Index('idx_orders_status', 'status'),
    Index('idx_orders_total', 'total'),
    // ✅ Can index source columns
    Index('idx_orders_order_number', 'order_number'),
  ];
}

// Generated SQL:
// CREATE TABLE orders (
//   id INTEGER PRIMARY KEY AUTOINCREMENT,
//   order_number TEXT NOT NULL,
//   subtotal REAL NOT NULL,
//   tax_percent REAL NOT NULL DEFAULT 0.1,
//   total REAL GENERATED ALWAYS AS (subtotal * (1 + taxPercent)) STORED,
//   status TEXT GENERATED ALWAYS AS (
//     CASE 
//       WHEN is_paid = 1 AND is_shipped = 1 THEN 'delivered'
//       WHEN is_paid = 1 AND is_shipped = 0 THEN 'processing'
//       WHEN is_paid = 0 THEN 'pending'
//       ELSE 'unknown'
//     END
//   ) STORED,
//   is_paid INTEGER NOT NULL DEFAULT 0,
//   is_shipped INTEGER NOT NULL DEFAULT 0
// );
// CREATE INDEX idx_orders_status ON orders(status);
// CREATE INDEX idx_orders_total ON orders(total);
// CREATE INDEX idx_orders_order_number ON orders(order_number);

Real-World Example

Complete e-commerce product catalog with generated columns

// lib/database/tables/products.dart
import 'package:drift/drift.dart';

class Products extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get sku => text().unique().customConstraint('CHECK (LENGTH(sku) >= 6)')();
  TextColumn get name => text()();
  TextColumn get description => text().nullable()();

  // 👇 Price information
  RealColumn get basePrice => real()
    .customConstraint('CHECK (base_price >= 0)')
    .named('base_price')();

  RealColumn get discountPercent => real()
    .withDefault(const Constant(0.0))
    .customConstraint('CHECK (discount_percent >= 0 AND discount_percent <= 100)')
    .named('discount_percent')();

  // 👇 Inventory
  IntColumn get stock => integer()
    .withDefault(const Constant(0))
    .customConstraint('CHECK (stock >= 0)')();

  IntColumn get reservedStock => integer()
    .withDefault(const Constant(0))
    .customConstraint('CHECK (reserved_stock >= 0)')
    .named('reserved_stock')();

  // 👇 Generated: current price with discount
  RealColumn get currentPrice => real()
    .generatedAs(
      const Constant("base_price * (1 - discount_percent / 100)"),
      stored: true,
    )
    .customConstraint('CHECK (current_price >= 0)')
    .named('current_price')();

  // 👇 Generated: available stock
  IntColumn get availableStock => integer()
    .generatedAs(
      const Constant("stock - reserved_stock"),
      stored: true,
    )
    .customConstraint('CHECK (available_stock >= 0)')
    .named('available_stock')();

  // 👇 Generated: stock status
  TextColumn get stockStatus => text()
    .generatedAs(
      const Constant('''
        CASE 
          WHEN stock - reserved_stock <= 0 THEN 'out_of_stock'
          WHEN stock - reserved_stock < 5 THEN 'low_stock'
          ELSE 'in_stock'
        END
      '''),
      stored: true,
    )
    .named('stock_status')();

  // 👇 Generated: product status
  TextColumn get productStatus => text()
    .generatedAs(
      const Constant('''
        CASE 
          WHEN isActive = 0 THEN 'inactive'
          WHEN stock - reserved_stock <= 0 THEN 'out_of_stock'
          WHEN isFeatured = 1 THEN 'featured'
          ELSE 'active'
        END
      ''')
    )
    .named('product_status')();

  // 👇 Flags
  BoolColumn get isActive => boolean()
    .withDefault(const Constant(true))
    .named('is_active')();

  BoolColumn get isFeatured => boolean()
    .withDefault(const Constant(false))
    .named('is_featured')();

  BoolColumn get isNew => boolean()
    .generatedAs(
      const Constant("julianday('now') - julianday(created_at) < 30"),
    )
    .named('is_new')();

  // 👇 Timestamps
  DateTimeColumn get createdAt => dateTime()
    .withDefault(currentDateAndTime)
    .named('created_at')();

  DateTimeColumn get updatedAt => dateTime()
    .nullable()
    .named('updated_at')();

  // 👇 Rating
  RealColumn get rating => real()
    .withDefault(const Constant(0.0))
    .customConstraint('CHECK (rating >= 0 AND rating <= 5)')();

  IntColumn get reviewCount => integer()
    .withDefault(const Constant(0))
    .customConstraint('CHECK (review_count >= 0)')
    .named('review_count')();

  // 👇 Indexes
  @override
  List<Index> get indexes => [
    // ✅ Index on STORED generated columns
    Index('idx_products_current_price', 'current_price'),
    Index('idx_products_stock_status', 'stock_status'),
    Index('idx_products_product_status', 'product_status'),
    Index('idx_products_available_stock', 'available_stock'),
    // ✅ Index on regular columns
    Index('idx_products_sku', 'sku'),
    Index('idx_products_is_active', 'is_active'),
    Index('idx_products_is_featured', 'is_featured'),
    Index('idx_products_created_at', 'created_at'),
  ];
}

// lib/database/tables/orders.dart
import 'package:drift/drift.dart';
import 'users.dart';
import 'products.dart';

class OrderItems extends Table {
  @override
  Set<Column> get primaryKey => {orderId, productId};

  IntColumn get orderId => integer()
    .references(Orders, #id, onDelete: KeyAction.cascade)
    .named('order_id')();

  IntColumn get productId => integer()
    .references(Products, #id, onDelete: KeyAction.restrict)
    .named('product_id')();

  IntColumn get quantity => integer()
    .customConstraint('CHECK (quantity > 0)')();

  // 👇 Price at time of order (snapshot)
  RealColumn get unitPrice => real()
    .customConstraint('CHECK (unit_price >= 0)')
    .named('unit_price')();

  RealColumn get discountPercent => real()
    .withDefault(const Constant(0.0))
    .customConstraint('CHECK (discount_percent >= 0 AND discount_percent <= 100)')
    .named('discount_percent')();

  // 👇 Generated: subtotal
  RealColumn get subtotal => real()
    .generatedAs(
      const Constant("quantity * unit_price"),
      stored: true,
    )();

  // 👇 Generated: discount amount
  RealColumn get discountAmount => real()
    .generatedAs(
      const Constant("subtotal * discount_percent / 100"),
      stored: true,
    )
    .named('discount_amount')();

  // 👇 Generated: final total
  RealColumn get total => real()
    .generatedAs(
      const Constant("subtotal - discount_amount"),
      stored: true,
    )();

  // 👇 Generated: item status
  TextColumn get itemStatus => text()
    .generatedAs(
      const Constant('''
        CASE 
          WHEN isShipped = 1 AND isDelivered = 1 THEN 'delivered'
          WHEN isShipped = 1 AND isDelivered = 0 THEN 'shipped'
          ELSE 'pending'
        END
      ''')
    )
    .named('item_status')();

  BoolColumn get isShipped => boolean()
    .withDefault(const Constant(false))
    .named('is_shipped')();

  BoolColumn get isDelivered => boolean()
    .withDefault(const Constant(false))
    .named('is_delivered')();

  // 👇 Indexes
  @override
  List<Index> get indexes => [
    Index('idx_order_items_order', 'order_id'),
    Index('idx_order_items_product', 'product_id'),
    Index('idx_order_items_status', 'item_status'),
  ];
}
// lib/database/database.dart - Usage
import 'package:drift/drift.dart';
import 'tables/products.dart';
import 'tables/order_items.dart';

@DriftDatabase(tables: [Products, OrderItems])
class EcommerceDatabase extends _$EcommerceDatabase {
  EcommerceDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());

  @override
  int get schemaVersion => 1;

  // 👇 Using generated columns
  Future<List<Product>> getProductsInStock() async {
    // availableStock is generated automatically
    return await (select(products)
      ..where((p) => p.availableStock > const Variable(0))
      ..orderBy([(p) => OrderingTerm(expression: p.currentPrice)]))
      .get();
  }

  Future<List<Product>> getFeaturedProducts() async {
    return await (select(products)
      ..where((p) => p.isFeatured.equals(true))
      ..where((p) => p.isActive.equals(true))
      ..orderBy([(p) => OrderingTerm(expression: p.rating, mode: OrderingMode.desc)]))
      .get();
  }

  Future<List<Product>> getDiscountedProducts() async {
    return await (select(products)
      ..where((p) => p.discountPercent > const Variable(0.0))
      ..where((p) => p.availableStock > const Variable(0)))
      .get();
  }

  // 👇 Create order with generated columns
  Future<int> createOrder(int userId, List<CartItem> items) async {
    return await transaction(() async {
      // Create order
      final orderId = await into(orders).insert(
        OrdersCompanion.insert(
          userId: userId,
          orderNumber: 'ORD-${DateTime.now().millisecondsSinceEpoch}',
        ),
      );

      // Add items with generated columns
      for (final item in items) {
        final product = await getProduct(item.productId);

        // availableStock is automatically checked
        if (product.availableStock < item.quantity) {
          throw Exception('Insufficient stock for ${product.name}');
        }

        await into(orderItems).insert(
          OrderItemsCompanion.insert(
            orderId: orderId,
            productId: item.productId,
            quantity: item.quantity,
            unitPrice: product.currentPrice, // 👈 Use generated currentPrice
            discountPercent: product.discountPercent,
          ),
        );

        // Update stock (reserved)
        await (update(products)..where((p) => p.id.equals(product.id)))
          .write(ProductsCompanion(
            reservedStock: Value(product.reservedStock + item.quantity),
          ));
      }

      return orderId;
    });
  }

  Future<Product> getProduct(int id) async {
    final product = await (select(products)..where((p) => p.id.equals(id)))
        .getSingleOrNull();
    if (product == null) throw Exception('Product not found');
    return product;
  }
}

class CartItem {
  final int productId;
  final int quantity;

  CartItem({required this.productId, required this.quantity});
}

Best Practices

  • Use STORED – For frequently queried generated columns
  • Use VIRTUAL – For rarely queried or expensive calculations
  • Index STORED columns – For better query performance
  • Use meaningful expressions – Make generated columns self-documenting
  • Test generated columns – Verify they compute correctly
  • Consider performance – STORED takes disk space, VIRTUAL takes CPU
  • Keep expressions simple – Complex expressions are harder to maintain
  • Document calculations – Explain business logic
  • Use CHECK constraints – Validate generated values

Common Mistakes

Mistake 1: Trying to index VIRTUAL columns

Wrong:

// 🚫 Cannot index VIRTUAL generated columns
TextColumn get status => text()
  .generatedAs(const Constant("...")) // VIRTUAL by default

@override
List<Index> get indexes => [
  Index('idx_status', 'status'), // ❌ Will fail
];

Correct:

// ✅ Use STORED for indexed columns
TextColumn get status => text()
  .generatedAs(const Constant("..."), stored: true)();

@override
List<Index> get indexes => [
  Index('idx_status', 'status'), // ✅ Works
];

Mistake 2: Using generated columns in INSERT

Wrong:

// 🚫 Cannot insert into generated columns
await into(products).insert(
  ProductsCompanion.insert(
    total: Value(100.0), // ❌ Generated column
  ),
);

Correct:

// ✅ Insert source columns only
await into(products).insert(
  ProductsCompanion.insert(
    price: 50.0,
    quantity: 2,
    // total is automatically computed
  ),
);

Mistake 3: Complex expressions with SQL injection risk

Wrong:

// 🚫 String concatenation is risky
final expression = "price * $discount"; // Could be injection

Correct:

// ✅ Use Constant with safe SQL
const Constant("price * discount_percent / 100");


Summary

Aspect STORED VIRTUAL
Storage Takes disk space No disk space
Performance Faster reads Slower reads
Indexable Yes No
Update On source change On query
Use Case Frequent queries Rare queries

Next Steps

Now you understand generated columns, let's dive deeper:


Did You Know?

  • Generated columns cannot be updated directly – They're computed automatically

  • SQLite supports generated columns since version 3.31.0 – March 2020

  • STORED columns can be used in PRIMARY KEY – But not VIRTUAL

  • Generated columns can reference other generated columns – As long as they're STORED

  • Date calculations in SQLite – Use strftime() and julianday() functions

  • CASE expressions are powerful – For conditional generated values

  • Generated columns can have CHECK constraints – To validate computed values

  • Expressions can include subqueries – In some SQLite versions