skip to content

When should a team drop to native SQL, and how do you keep dialect differences from spreading through a codebase?

level: principalimportance: should knowfreq 45%

answer

  1. an exception with a justification
  2. expressiveness, measured plan, set-based shape
  3. one catalog, not scattered strings
  4. one implementation per engine, not branches
  5. capability, not version sniffing

basics

~20 s

Drop below the mapper only for what the object-level language cannot express, or a plan you measured, never for taste. Contain it with one statement catalog, one implementation per engine, and tests on every supported engine.

solid answer

~50 s

Treat a native statement as a **deliberate exception with an owner**, justified by one of three things: the object-level language genuinely cannot express the operation, a measured plan problem no fetch or hint reaches, or a set-based operation where building objects is pure waste. "It is easier to write" is not on the list. Containment is a structural decision: keep statements in a **named catalog** rather than inline at call sites, so a schema change has one place to visit; where two supported engines differ, provide **one implementation per engine behind one interface** rather than branching on an engine flag at the call site; select on a declared **capability** rather than sniffing a version string. Then make the cost visible: count the statements, test against every engine you claim to support, and add the catalog to the checklist for any migration that renames a column.

go deeper

for a junior

Take away the habit rather than the policy: raw SQL is an exception you should be able to justify, and it belongs somewhere named rather than buried inline in a service method.

for a middle

Be able to state the justification bar and the containment mechanics: a statement catalog, aliased result labels, and one place per engine for the parts that genuinely differ.

for a senior

Show that you operate it — a rename checklist that sweeps the catalog, tests against each supported engine, and a plan measurement behind any statement justified by performance.

for a principal

Own the position itself: whether portability is a goal, what the trend in raw statement count means, and when a codebase carrying two query languages has crossed into a comprehension cost nobody priced.

## What you are actually deciding A native statement buys expressive power and plan control with **coupling**: to a schema, to a dialect, and to a class of change no compiler will help with. Both halves are real, so the question is never "is raw SQL allowed" but "under what justification, and held where". ## When it is justified 1. **Expressiveness.** The operation exists in SQL and not in the object-level language — a window function, a recursive query, a set operation, an aggregate shape the mapper cannot form. This is the strongest case, and the least arguable. 2. **A measured plan need.** The generated statement is demonstrably wrong for the workload and no fetch strategy or hint reaches it. "Measured" means an execution plan and a number, before and after. 3. **Shape.** The work is set-based or scalar, and materialising objects to do it would be waste. And the justifications that should not survive review: unfamiliarity with the object-level language; a preference for reading SQL; copying a statement out of a database console because it was already written; "we might need it later". ## Where the statements live | Placement | What it costs you | |---|---| | Inline at the call site | statements scattered, no inventory, renames missed | | One named catalog (files or named definitions) | one place to grep, one place to review, small indirection | | Per-engine implementations behind one interface | more files, but divergence is visible and bounded | The catalog is the load-bearing decision. It converts "how many raw statements do we have and what do they touch" from an archaeology exercise into a listing, and it gives a schema migration a checklist. Give each statement a name, keep the name at the call site, and let the text sit next to the other text. ## Containing dialect divergence If you support more than one engine — and note that supporting a different engine in tests than in production is *already* supporting two — divergence needs a shape: - **One implementation per engine behind one interface**, chosen once at startup. The alternative, an engine check inside each statement builder, spreads the same conditional through the codebase and guarantees one gets missed. - **Select on capability, not on identity.** "This deployment supports the upsert form we need" is a fact you can declare and test; "this is engine X at version Y" is a fact that drifts and invites a growing table of exceptions. - **Keep the divergent surface small and named.** Paging, string and date functions, locking clauses, conflict handling and identifier quoting are where engines actually differ; the rest of a statement is usually standard. - **Test what you claim.** A dialect abstraction with only one implementation exercised is an untested abstraction; it will be wrong the day the second engine is switched on. Either run the suite against every supported engine or stop claiming to support them. ## Making the cost visible - **Count them.** The number of native statements, and its trend, is a better health signal than any argument about whether one of them is justified. - **Review them as schema-coupled code.** A rename, a type change, or a table split has to sweep the catalog. Put that step in the migration checklist rather than in someone's memory. - **Own the mapped shapes.** Result labels and select-list order are a contract with the mapping; name and alias them so the contract is written down. - **Watch for the second query language problem.** Past some threshold, a codebase in which half the reads are object-level and half are raw asks every reader to know both, and the two are refactored at different speeds. That threshold is a judgement, but it exists, and noticing it is the lead's job. ## The honest single-engine case Many systems will only ever run on one engine. Paying for a dialect abstraction there is real waste, and saying so is legitimate — **provided it is a recorded decision** rather than an accident. The recorded version names the engine, states that portability is not a goal, and notes what would have to change if that ever became false. The accidental version looks identical today and costs a quarter of engineering time the day someone acquires a company running something else. ## What a good answer sounds like A justification bar with three items on it, a catalog so the inventory is knowable, one implementation per engine rather than conditionals at call sites, capability-based selection, a migration checklist that includes the catalog, and an explicit position on whether portability is a goal at all. Plus the metric: how many statements, and is that number growing.

  • Why is selecting behaviour by declared capability better than checking the engine and version?
    A capability names the thing you actually depend on, can be asserted in a test, and stays true across versions that keep it. Engine-and-version checks accumulate exceptions, drift as versions move, and hide the real dependency behind an identity that says nothing about what the code needs.
  • How would you decide that a codebase now holds too many native statements?
    Look at the trend and the reasons, not the count alone. Rising numbers whose justifications are convenience rather than expressiveness, renames that keep breaking statements, and reviewers who cannot say what the raw layer covers all say the exception has become the habit.
  • What is wrong with a dialect abstraction that has only ever had one implementation?
    It is untested indirection. The boundary was drawn against imagined differences rather than real ones, so it will usually be in the wrong place when a second engine arrives, while costing indirection every day until then. Either exercise a second implementation or drop the abstraction and record the single-engine decision.

saying these in an interview costs you the question

  • Justifies raw SQL by preference rather than a need or a measurement
  • Scatters engine conditionals through call sites instead of one implementation each
  • Claims multi-engine support while testing against only one
  • Leaves statements inline so no inventory of them exists
  • Omits raw statements from the checklist for a column rename
  • Builds a dialect abstraction for portability nobody has asked for