Queries and transactions
Prepare and bind every request value
Keep untrusted values separate from SQL structure and handle D1 result metadata explicitly.
8 minute lesson
Every value that arrives with a request is untrusted. The way you keep it harmless is structural: create statements with env.DB.prepare() and place request values in 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 contains only structure. The values travel separately, so a title like '); drop table notes; -- is stored as those literal characters, never executed. Never build a SQL string by concatenating a title, ID, sort field, or tenant value — one template literal with ${userInput} inside is the entire SQL injection vulnerability class. This rule has no exceptions for values you believe are safe, because the next developer will not know why you believed it.
Pick the result shape on purpose
Each way of running a statement returns something different, and picking the right one removes a class of bugs:
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 matching zero rows is not an exception — it “succeeds” while changing nothing. A successful HTTP request should not hide a failed database operation, and meta.changes is how you notice. Without this 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 represent 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, allowlist it:
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()
The interpolated value comes from your own fixed map, never from the request.
Now implement note creation and lookup with bound parameters, then test quotes and injection-shaped text as ordinary data. The malicious string should come back from a select exactly as it was stored.
Lesson completed