skip to content

What does scanning a column into sql.RawBytes give you, and what rules must you follow to use it safely?

level: seniorimportance: nice to knowfreq 25%

answer

  1. Scan usually copies; this destination does not
  2. the driver owns that memory
  3. valid only until the row moves on
  4. copy it or lose it
  5. nil slice means the column was NULL

basics

~20 s

sql.RawBytes aliases memory owned by the driver, so Scan skips the copy a *[]byte or *string destination makes. The bytes stay valid only until the next Next, Scan or Close call, must not be mutated, and are nil for NULL.

solid answer

~50 s

`sql.RawBytes` is a `[]byte` that, after `Scan`, points at memory the **driver** owns rather than at a copy. That is the whole benefit: on a back-office dump streaming millions of raw column values, scanning into `*[]byte` or `*string` allocates and copies once per column per row, and `sql.RawBytes` does not. The price is a hard lifetime rule — the bytes are only valid until the next `rows.Next`, `rows.Scan` or `rows.Close`, so you must consume or copy them inside the loop iteration, with `string(b)` or `bytes.Clone(b)`. You must also not mutate the slice, it is rejected by the `Scan` of the `*sql.Row` that `QueryRow` returns, and a NULL column simply leaves it `nil` with no error — which makes it one of the few destinations that accepts NULL without a wrapper type. Use it only where you have measured the copy mattering; otherwise a plain destination is safer.

code

go · 18 lines
go
var status, note sql.RawBytes

for rows.Next() {
	if err := rows.Scan(&status, &note); err != nil {
		return err
	}

	// A NULL column leaves the RawBytes nil, with no error.
	if status == nil {
		fmt.Fprint(out, "NULL")
	} else {
		// Safe: written before the next rows.Next call.
		out.Write(status)
	}

	// Keeping a value past this iteration requires a copy:
	// kept = append(kept, bytes.Clone(note))
}

go deeper

for a junior

Know that this destination borrows the driver's memory instead of copying, and that most code should use a plain []byte or string destination unless there is a specific reason not to.

for a middle

Explain the lifetime rule precisely — valid until the next Next, Scan or Close — plus the no-mutation rule, and show the copy you would make with bytes.Clone or a string conversion to keep a value.

for a senior

Show the judgment: measure before adopting it, confine it to code that consumes each value within the iteration, and recognise the silent wrong-data failure mode when a retained slice is reused by the driver.

for a principal

Own whether this optimisation is allowed in the codebase at all. Weigh a measured allocation saving in one exporter against a borrowed-memory hazard that reviewers must catch every time, and decide where it may appear.

## What sql.RawBytes actually is `sql.RawBytes` is declared as a byte slice, but its contract is unusual: after a successful `Scan`, it points at memory **owned by the database driver**, not at a copy made for you. Every other byte-ish destination copies. `*[]byte` gets a fresh slice; `*string` gets a newly allocated string; `*any` gets a copied `[]byte`. `*sql.RawBytes` gets the driver's own buffer. That is the entire reason the type exists: it removes an allocation and a copy per column per row. ## The rules that come with it **Lifetime.** The bytes are valid only until the next call to `rows.Next`, `rows.Scan` or `rows.Close` on the same `*sql.Rows`. After that the driver is free to overwrite the buffer with the next row. So inside the loop body you may read, compare, hash or write the bytes; you may not stash the slice in a struct, append it to a slice of results, or hand it to a goroutine that outlives the iteration. If you need it later, copy: `string(b)` allocates a copy, and `bytes.Clone(b)` gives an independent `[]byte`. **No mutation.** You are looking at the driver's buffer. Writing through the slice corrupts state you do not own. **Not on Row.Scan.** The `*sql.Row` returned by `QueryRow` closes its underlying rows as part of `Scan`, so the borrowed memory would be invalid the instant `Scan` returned. `Row.Scan` therefore refuses a `*sql.RawBytes` destination and returns an error rather than handing you a dangling slice. `sql.RawBytes` is a `Rows`-loop tool only. **NULL is nil.** A NULL column scanned into `sql.RawBytes` leaves it `nil` and returns no error. This makes it, along with `*[]byte` and `*any`, one of the few destinations that accepts NULL without a wrapper — and it distinguishes NULL (`b == nil`) from an empty value (`len(b) == 0` but `b != nil`), though whether a given driver preserves that distinction for empty strings is worth checking before you rely on it. ## Where it earns its place The honest use case is narrow: a tool that streams raw column values straight out again without keeping them. An internal back-office exporter dumping a wide legacy table — half of whose columns are nullable — writes each value to an `io.Writer` inside the loop and never retains anything, which is exactly the shape the lifetime rule permits. At a few million rows across a dozen columns, the copies you avoid are real. Outside that shape it is a poor trade. In a normal handler that reads a row into a struct and returns it, the values must outlive the loop, so you would copy them anyway — at which point `sql.RawBytes` has bought nothing and added a footgun. Reach for it after a benchmark with `-benchmem` shows the scan copies dominating, not before. ## The same rule, without the type The ownership rule is not special to `sql.RawBytes`. Any custom `sql.Scanner` whose `Scan(src any)` receives a `[]byte` is being handed driver-owned memory under the same terms, and must copy anything it retains. `sql.RawBytes` merely makes the borrowing explicit and visible in the destination's type rather than leaving it as a paragraph of documentation people skip. Reviewing a `Scan` implementation, "where does this `[]byte` go after the method returns?" is the question to ask. ## How this fails in production The failure mode is nasty because it is not a crash. Code appends `sql.RawBytes` values into a result slice; under light load the driver happens to allocate a fresh buffer per row and everything looks correct; under load, or after a driver upgrade that starts reusing buffers, every entry in the result slice ends up showing the last row's bytes, or a mixture. There is no panic and no error — just wrong data, appearing only at volume. The race detector will not find it either, because nothing here is a data race between goroutines; it is a lifetime bug in one goroutine. That asymmetry — cheap when correct, silently wrong when not — is why the reviewing question for `sql.RawBytes` is always the same: does this value get used and dropped before the next `rows.Next`, and if not, where is the copy?

  • Why does the Scan of the *sql.Row returned by QueryRow reject a *sql.RawBytes destination?
    Because that `Scan` closes the underlying rows before it returns, releasing the driver memory the slice would alias. Handing back a slice into freed or reused memory would be a silent corruption, so it returns an error instead. `sql.RawBytes` is only usable inside a `rows.Next` loop, where the caller can see the lifetime.
  • How would you know whether using sql.RawBytes is worth it here?
    Measure. Write a benchmark over a representative row count with `-benchmem` and compare the plain destination against `sql.RawBytes`; if allocations per row barely move, or the query time dominates, keep the plain destination. The type is only worth its lifetime hazard when scan-time copying is a measured cost, which in practice means wide rows at high volume.
  • Does the same ownership rule apply to the []byte handed to a custom Scan method?
    Yes. A `[]byte` reaching `Scan(src any)` is driver-owned on exactly the same terms and may be reused for the next row, so anything you retain must be copied — with `string(src)` or `bytes.Clone(src)`. `sql.RawBytes` just makes that borrowing visible in the type instead of leaving it to documentation.

saying these in an interview costs you the question

  • Appends sql.RawBytes values into a slice that outlives the loop
  • Thinks sql.RawBytes is just a shorter name for []byte
  • Mutates the slice in place to normalise the value
  • Expects an error when a NULL is scanned into it
  • Uses it in a normal handler without measuring the copy cost