Queries and transactions

Prepare and bind every request value

Keep untrusted values separate from SQL structure and handle D1 result metadata explicitly.

Every value that arrives with a request is untrusted. The way to keep it harmless is structural. Write the SQL with env.DB.prepare() and pass the request values through bind():

const note = await env.DB.prepare(
  'select id, title, body from notes where id = ? and user_id = ?'
).bind(noteId, userId).first()

The SQL string only contains structure. The values travel separately. So a title like '); drop table notes; -- gets stored as those exact characters, and never runs.

Never build SQL by concatenating a title, an ID, a sort field, or a tenant value. One template literal with ${userInput} inside is the entire SQL injection problem. And there is no exception for values you believe are safe, because the next developer won’t know why you believed it.

Pick the result shape on purpose

Each way of running a statement returns something different:

const row  = await stmt.first()   // one object, or null
const all  = await stmt.all()     // { results, success, meta }
const raw  = await stmt.raw()     // arrays of values, no column names
const info = await stmt.run()     // meta for writes

first() returning null is your “not found” signal. Handle it, instead of reading properties off it.

For writes, read the metadata:

const { meta } = await env.DB.prepare(
  'update notes set title = ? where id = ? and user_id = ?'
).bind(title, noteId, userId).run()

if (meta.changes === 0) {
  return new Response('Not found', { status: 404 })
}

An UPDATE that matches zero rows is not an error. It succeeds and changes nothing. Without the meta.changes check, an update to someone else’s note returns 200 while doing nothing, and both the user and your logs believe it worked.

Identifiers cannot be bound

Bound parameters are values, not table or column names. order by ? binds the string as a constant, so the sort silently does nothing. When the client picks a sort field, use an allowlist:

const sortable = { date: 'created_at', title: 'title' }
const column = sortable[requestedSort] ?? 'created_at'
const rows = await env.DB.prepare(
  `select id, title from notes where user_id = ? order by ${column} desc`
).bind(userId).all()

Notice the interpolated value comes from your own fixed map, never from the request. That’s the one place a template literal is fine.

Try this on the notes app: implement note creation and lookup with bound parameters, then save a title full of quotes and injection-shaped text. A select should give it back exactly as you stored it. Boring data, which is the point.

Lesson completed