Most database bugs in Xojo apps aren't subtle. They're the same three mistakes, repeated across thousands of codebases, by developers who either didn't know better or figured they'd fix it later.

Later never comes. The string-concatenated query ships. The missing error handler stays missing. The database layer stays tangled with the UI. And six months later, someone is debugging a production crash at the worst possible time.

This post is about doing it right from the start. Prepared statements, proper error handling, and a data layer architecture that holds up over time.

The Problem With String-Concatenated Queries

Here's a query you'll see in Xojo codebases written by developers who learned from older tutorials:

// New API (Xojo 2019r2+)
// WRONG — never do this

Dim username As String = UsernameField.Text
Dim query As String = "SELECT * FROM users WHERE username = '" + username + "'"
Dim rs As RowSet = db.SelectSQL(query)

This works. Right up until it doesn't.

If username contains a single quote, say a user named O'Brien, the query breaks. If it contains ' OR '1'='1, your query returns every row in the table. If it contains '; DROP TABLE users; --, you know where this is going.

SQL injection isn't a theoretical risk. It's a well-documented, actively exploited attack vector. And string concatenation is the door that lets it in.

The fix isn't complicated. It's a PreparedStatement.

PreparedStatements: The Right Way to Query

A PreparedStatement separates the query structure from the data. The database engine receives the query as a template, then receives the values separately. User input is never interpreted as SQL. It's always treated as data.

Here's the same query, done correctly:

// New API (Xojo 2019r2+)
// Correct — use a PreparedStatement for any query with user-supplied values

Dim ps As SQLitePreparedStatement = _
  db.Prepare("SELECT * FROM users WHERE username = ?")
ps.BindType(0, SQLitePreparedStatement.SQLITE_TEXT)
ps.Bind(0, UsernameField.Text)

Dim rs As RowSet = ps.SelectSQL()

The ? placeholder is where the value goes. The Bind call supplies the value. The database engine handles the rest: escaping, quoting, type coercion. You don't touch any of it.

For INSERT and UPDATE operations, the same pattern applies:

// New API (Xojo 2019r2+)
// INSERT with PreparedStatement

Dim ps As SQLitePreparedStatement = _
  db.Prepare("INSERT INTO users (username, email, created_at) VALUES (?, ?, ?)")

ps.BindType(0, SQLitePreparedStatement.SQLITE_TEXT)
ps.BindType(1, SQLitePreparedStatement.SQLITE_TEXT)
ps.BindType(2, SQLitePreparedStatement.SQLITE_TEXT)

ps.Bind(0, username)
ps.Bind(1, email)
ps.Bind(2, DateTime.Now.SQLDateTime)

ps.ExecuteSQL()

Note the BindType call before each Bind. Xojo's SQLite prepared statement implementation requires you to declare the type before binding the value. Skip it, and the binding silently fails or behaves unexpectedly. Always pair them.

Error Handling: The Happy Path Is Not Enough

I've reviewed a lot of Xojo database code. The most common failure isn't bad queries. It's missing error handling. The developer wrote for the happy path, shipped it, and got a crash report when something unexpected happened.

Database operations fail. Connections drop. Disk space runs out. Constraints get violated. Your code needs to handle these cases, every time.

Xojo's database classes use exceptions for error reporting. Wrap every database operation in a Try...Catch block:

// New API (Xojo 2019r2+)
// Correct error handling for a database query

Try
  Dim ps As SQLitePreparedStatement = _
    db.Prepare("SELECT id, username, email FROM users WHERE id = ?")
  ps.BindType(0, SQLitePreparedStatement.SQLITE_INTEGER)
  ps.Bind(0, userId)

  Dim rs As RowSet = ps.SelectSQL()

  If rs <> Nil And Not rs.AfterLastRow Then
    // Process results
    Do Until rs.AfterLastRow
      // Access columns by name — more resilient than by index
      Dim name As String = rs.Column("username").StringValue
      Dim email As String = rs.Column("email").StringValue
      rs.MoveToNextRow
    Loop
  End If

Catch e As DatabaseException
  // Log the error — don't silently swallow it
  System.Log(System.LogLevelError, "Query failed: " + e.Message)
  // Surface it appropriately to the caller or the user
  Raise e

End Try

A few things worth noting in that pattern:

Access columns by name, not by index. rs.Column("username") is more resilient than rs.Column(0). If the query changes and the column order shifts, named access still works. Index access silently returns the wrong value.

Don't swallow exceptions silently. A Catch block that does nothing is worse than no error handling at all. It hides failures and makes them impossible to diagnose. Log it. Re-raise it. Surface it to the user. Do something.

Check for Nil before using a RowSet. A failed query can return Nil. An empty result set is not Nil. It's a valid RowSet where AfterLastRow is immediately true. Know the difference.

Transactions: Protecting Related Operations

When you have a group of database operations that must all succeed or all fail together, use a transaction. Without one, a partial failure leaves your data in an inconsistent state.

The pattern is straightforward:

// New API (Xojo 2019r2+)
// Transaction wrapping multiple related operations

Try
  db.BeginTransaction

  Dim ps1 As SQLitePreparedStatement = _
    db.Prepare("INSERT INTO orders (customer_id, total) VALUES (?, ?)")
  ps1.BindType(0, SQLitePreparedStatement.SQLITE_INTEGER)
  ps1.BindType(1, SQLitePreparedStatement.SQLITE_DOUBLE)
  ps1.Bind(0, customerId)
  ps1.Bind(1, orderTotal)
  ps1.ExecuteSQL()

  Dim orderId As Integer = db.LastRowID

  Dim ps2 As SQLitePreparedStatement = _
    db.Prepare("INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)")
  ps2.BindType(0, SQLitePreparedStatement.SQLITE_INTEGER)
  ps2.BindType(1, SQLitePreparedStatement.SQLITE_INTEGER)
  ps2.BindType(2, SQLitePreparedStatement.SQLITE_INTEGER)

  For Each item As OrderItem In items
    ps2.Bind(0, orderId)
    ps2.Bind(1, item.ProductId)
    ps2.Bind(2, item.Quantity)
    ps2.ExecuteSQL()
  Next

  db.CommitTransaction

Catch e As DatabaseException
  // Roll back everything if any step fails
  db.RollbackTransaction
  System.Log(System.LogLevelError, "Order save failed: " + e.Message)
  Raise e

End Try

BeginTransaction, do the work, CommitTransaction. If anything throws, RollbackTransaction. The data is always in a valid state.

Transactions also have a performance benefit. SQLite, in particular, is dramatically faster when writes are batched inside a transaction rather than executed individually. If you're inserting or updating large numbers of rows in a loop, wrapping the loop in a transaction isn't just safer. It's measurably faster.

Separating the Database Layer from Your UI

This is where architecture matters. A lot of Xojo apps put database calls directly in button event handlers, window open events, and list population methods. It works. It's also a maintenance problem that compounds over time.

When your SQL is scattered through UI event handlers, every change to a query requires you to hunt through UI code. Testing logic requires opening windows. Reusing a query means copying it, and then keeping two copies in sync.

The better pattern is a dedicated data layer: a module or set of classes whose only job is database access. Your UI calls methods on the data layer. It never writes SQL.

A simple example:

// New API (Xojo 2019r2+)
// A dedicated data access method — lives in a Module, not a Window

Function GetUserByID(db As SQLiteDatabase, userId As Integer) As RowSet
  Dim ps As SQLitePreparedStatement = _
    db.Prepare("SELECT id, username, email FROM users WHERE id = ?")
  ps.BindType(0, SQLitePreparedStatement.SQLITE_INTEGER)
  ps.Bind(0, userId)
  Return ps.SelectSQL()
End Function

Your UI code becomes:

// New API (Xojo 2019r2+)
// Clean UI code — no SQL, no database details

Dim rs As RowSet = GetUserByID(App.Database, selectedUserId)
If rs <> Nil And Not rs.AfterLastRow Then
  UsernameLabel.Text = rs.Column("username").StringValue
End If

The UI doesn't know what database engine you're using. It doesn't know what the query looks like. It calls a method, gets data back, and displays it. When the query changes, you change it in one place.

The Crawl, Walk, Run Path

Crawl: Use PreparedStatement for every query that takes user input. No exceptions. Build that habit before anything else.

Walk: Add Try...Catch to every database operation. Log errors. Verify your RowSet checks are correct. Stop writing for only the happy path.

Run: Refactor your database calls into a dedicated data layer. Keep SQL out of UI event handlers. Design methods that do one thing, return clean results, and can be reused across your application.

The Bottom Line

Database code that works in your test environment and breaks in production almost always has one of three problems: string concatenation instead of prepared statements, missing error handling, or data access logic tangled with UI code.

None of these are hard to fix. They're just easy to skip when you're moving fast.

Use PreparedStatement. Handle exceptions. Keep your SQL out of your windows.

The data your users trust you with deserves that much.