Query hints
Attach statement-level hints to a query with
Hint(...), for example SQL ServerOPTION (RECOMPILE).
Prerequisites: Querying and projections · CTE · Provider overview
Overview
Hint returns a new command carrying one or more
statement-level hints. Hints are provider specific: the command stores plain strings and the active
ISqlDialect decides where and how they are rendered. The method takes
params string[], so one or more hints can be passed in a single call or accumulated across repeated
calls; the two forms are equivalent:
// one call with several arguments:
var rows = dataContext.From<IComplexEntity>()
.Where(c => c.Id > 1)
.Select(c => new { c.Id, c.RequiredString })
.Hint("recompile", "fast 10")
.ToList();
// equivalent two-call form:
var rows = dataContext.From<IComplexEntity>()
.Where(c => c.Id > 1)
.Select(c => new { c.Id, c.RequiredString })
.Hint("recompile")
.Hint("fast 10")
.ToList();
select id, somestring
from complex_entity where (id > 1) option (recompile, fast 10)
Blank hints are ignored. The hint list is part of the query plan key, so a hinted command never reuses the cached plan of an otherwise identical unhinted command (and vice versa), and two commands with different hints do not share a plan.
Query tags
WithTag(string? tag) attaches a free-form tag to a query. Unlike Hint, the tag is a plain SQL
comment (/* tag */), not an optimizer hint, so it renders on every SQL provider — it is not
gated. It is emitted immediately after the SELECT keyword:
var rows = dataContext.From<IComplexEntity>()
.WithTag("reports.orders")
.Where(c => c.Id > 1)
.Select(c => new { c.Id })
.ToList();
select /* reports.orders */ id
from complex_entity where (id > 1)
Use it to identify a statement in a profiler, the server log or a server-side query store
(pg_stat_activity, SQL Server Query Store, ClickHouse system.query_log). Line breaks and the
comment delimiters */ and /* are neutralised — SQL Server nests block comments, so /* is escaped
too — and the comment always opens with a space, so a tag starting with ! or + cannot become a
MySQL/MariaDB executable comment or optimizer hint. The tag is part of the plan key, so two commands
that differ only in their tag never share a cached plan; passing null or an empty string clears it.
The in-memory provider accepts the call and ignores it (no SQL is generated). When a query also carries
Hint(...), the optimizer-hint comment is placed first (select /*+ hint */ /* tag */ ...) so
pg_hint_plan and the MySQL/MariaDB optimizer still recognise it.
Combining with a recursive CTE
SQL Server allows only one OPTION clause per statement. When a query also declared a CTE recursion
limit, the hints are merged into that same clause:
var sql = dataContext.WithRecursive("nums", body, 100)
.From("nums")
.Select(t => new CteNumberRow { n = t["n"].AsInt })
.Hint("recompile");
-- ends with:
... option (maxrecursion 100, recompile)
Locking table hints
FromOptions.WithTableHint(params string[] hints) attaches SQL Server locking hints (nolock/updlock/holdlock) to the primary
physical table. SQL Server renders them as a WITH (...) clause between the table name and its alias:
var rows = dataContext.From<IComplexEntity>(o => o.WithTableHint("nolock"))
.Select(c => new { c.Id })
.ToList();
select id
from complex_entity with (nolock)
JoinOptions.WithJoinTableHint(params string[] hints) attaches the same kind of table hint to a
join when it is declared, passed through that join's trailing lambda, like WithJoinHint. It is
not the same as JoinOptions.WithJoinHint (an
optimizer join hint such as loop/hash/merge, rendered inside the join clause) and not the same as
WithTablesInScopeHint (which covers every physical table). SQL Server renders it as a WITH (...)
clause on that joined table only:
var rows = dataContext.From<IComplexEntity>()
.Join<ISimpleEntity>((a, b) => a.Id == b.Id, j => j.WithJoinTableHint("nolock"))
.Select(p => new { p.Item1.Id })
.ToList();
select t1.id
from complex_entity as [t1]
inner join simple_entity with (nolock) as [t2] on t1.id = t2.id
Combining it with WithTablesInScopeHint still yields a single WITH (...) clause on that table: the
join-local hint is listed first, then the scope hints (which still cover every other physical table):
var rows = dataContext.From<IComplexEntity>()
.WithTablesInScopeHint("holdlock")
.Join<ISimpleEntity>((a, b) => a.Id == b.Id, j => j.WithJoinTableHint("nolock"))
.Select(p => new { p.Item1.Id })
.ToList();
select t1.id
from complex_entity with (holdlock) as [t1]
inner join simple_entity with (nolock, holdlock) as [t2] on t1.id = t2.id
The hints are rendered verbatim, so only pass trusted values. A provider opts in through
SupportsTableHints and MakeTableHints (SQL Server); other dialects reject a
command that carries table hints with NotSupportedException. Pass j => j.WithJoinTableHint(...) to target a chosen
join; WithTableHint still covers only the primary table. A per-join table hint can only be rendered on a
physical-table join: an APPLY join (or a derived-table, table-valued-function, XML/pivot or raw-SQL join
source) has no table name to attach a WITH (...) clause to and is rejected with
InvalidOperationException. Passing only blank/null hints leaves any previously attached hints unchanged.
WithTableHint and WithJoinTableHint both require a dialect with table hints, so SQLite, ClickHouse and
the in-memory provider reject them with NotSupportedException.
PostgreSQL has no WITH (...) table-hint syntax — that clause is T-SQL-only. To lock a single table on
PostgreSQL or MySQL, use the source-level row lock (FromOptions.ForUpdate/
FromOptions.ForShare, or JoinOptions.ForUpdate/
JoinOptions.ForShare on a join): it renders
FOR UPDATE OF <alias>/FOR SHARE OF <alias> — see
Row locking. Optimizer hints via
pg_hint_plan are the separate Hint(...) surface described above.
Index hints
FromOptions.WithIndex(params string[] indexes) asks the planner to consider a named index on the
primary physical table. The overload WithIndex(IndexHintKind kind, params string[] indexes) selects the
intent (IndexHintKind.Use/Force/Ignore), and WithoutIndex()
suppresses index use. Each dialect renders its native form after the table name and before its alias:
var rows = dataContext.From<IComplexEntity>(o => o.WithIndex("ix_complex_id"))
.Select(c => new { c.Id })
.ToList();
-- MySQL/MariaDB (also `force index (...)` / `ignore index (...)`):
select id
from complex_entity use index (ix_complex_id)
-- SQLite (exactly one index; `WithoutIndex()` renders `not indexed`):
select id
from complex_entity indexed by ix_complex_id
-- SQL Server (merged into the single table-hint clause):
select id
from complex_entity with (index(ix_complex_id))
A provider opts in through ISqlDialect.IndexHints and
IIndexHintRenderer (MySQL/MariaDB, SQLite, SQL Server).
PostgreSQL (without pg_hint_plan), ClickHouse and the in-memory provider have no native index hint and
reject a command that carries one with NotSupportedException. On SQL Server an index hint is merged with
a locking WithTableHint into a single with (nolock, index(...)) clause, and an Ignore hint is rejected
(there is no index-ignore hint); SQLite accepts exactly one index name (its INDEXED BY takes one). The
names are rendered verbatim, so only pass trusted values, and the hint list is part of the plan key.
Providers
| Provider | Query hints |
|---|---|
| SQL Server | Supported: rendered as a trailing OPTION (hint, ...) clause. |
| PostgreSQL | Supported: rendered as an inline /*+ hint ... */ comment immediately after SELECT, the position the optional pg_hint_plan extension reads; on a server without the extension it is an ordinary comment. |
| MySQL / MariaDB | Supported: rendered as an inline /*+ hint ... */ optimizer-hint comment immediately after SELECT. |
| SQLite | Not supported: building the SQL throws NotSupportedException. |
| ClickHouse | Not supported: use Settings(...) instead; Hint(...) throws NotSupportedException. |
Multiple hints are joined inside one comment (space-separated), the form both pg_hint_plan and the
MySQL/MariaDB optimizer expect:
// PostgreSQL: select /*+ SeqScan(simple_entity) */ id
// from simple_entity
var pg = dataContext.From<ISimpleEntity>()
.Select(x => new { x.Id })
.Hint("SeqScan(simple_entity)");
// MySQL / MariaDB: select /*+ MAX_EXECUTION_TIME(1000) */ id
// from simple_entity
var my = dataContext.From<ISimpleEntity>()
.Select(x => new { x.Id })
.Hint("MAX_EXECUTION_TIME(1000)");
A provider opts in through SupportsQueryHints and RenderQueryHints; the
builder rejects a command that carries hints on a dialect that reports false.
Join, subquery and tables-in-scope hints
Hint(...) is statement-level. Four builder methods attach a hint to a narrower part of the query;
each dialect renders the form it has, or rejects the command:
var rows = dataContext.From<ISimpleEntity>(o => o.WithTableHint("nolock"))
.Join<IComplexEntity>((s, c) => s.Id == c.Id, j => j.WithJoinHint("loop"))
.Select(p => new { p.Item1.Id })
.ToList();
JoinOptions.WithJoinHint(string hint)attaches a hint to a join (j => j.WithJoinHint("loop")) when the join is declared. SQL Server inserts it inside the join clause (inner loop join,left hash join); a hint on aCROSS/APPLYjoin is rejected. PostgreSQL, MySQL and MariaDB fold it into the statement-level/*+ ... */comment.JoinOptions.WithJoinTableHint(params string[] hints)attaches a locking table hint to the join it is declared on — the per-join counterpart ofWithTableHint, and distinct from the optimizerWithJoinHintabove. SQL Server renders aWITH (hint, ...)clause on that joined table; other dialects reject it withNotSupportedException. Only a physical-table join can carry it: an APPLY, derived-table, table-valued-function or XML/pivot join source is rejected withInvalidOperationException. See Locking table hints.FromOptions.WithSubQueryHint(string hint)attaches a hint to the derived-table source of aFrom(subQuery, o => o.WithSubQueryHint(...))builder. PostgreSQL/MySQL/MariaDB fold it into/*+ ... */; SQL Server rejects it, because T-SQL cannot append a query hint to a subselect.WithTablesInScopeHint(params string[] hints)applies the hints to every physical table in the query's scope. SQL Server adds aWITH (hint, ...)clause to the primary and every joined table (the multi-table counterpart ofWithTableHint); PostgreSQL/MySQL/MariaDB fold it into the statement comment.
-- SQL Server:
select t1.id
from simple_entity as [t1] inner loop
join complex_entity as [t2] on t1.id = t2.id
-- PostgreSQL (pg_hint_plan):
select /*+ HashJoin(t1 t2) */ id
from simple_entity as "t1"
join complex_entity as "t2" ...
-- MySQL 8:
select /*+ JOIN_ORDER(t1, t2) */ id
from simple_entity as `t1`
join complex_entity as `t2` ...
A blank hint is rejected with ArgumentException when the lambda runs. For the inline-comment dialects the hint text is rendered verbatim, so write
the source aliases yourself (HashJoin(t1 t2)): nextorm's aliases are assigned at render time and are not
exposed. All four hints are part of the plan key. On SQLite, ClickHouse and the in-memory provider they
are rejected with NotSupportedException.
Tables-in-scope hints on joined DELETE/UPDATE
WithTablesInScopeHint is honoured by the multi-table DELETE and UPDATE builders, not only by
SELECT: the hints reach the target and every joined physical source.
dataContext.From<IComplexEntity>()
.WithTablesInScopeHint("SeqScan(t1)") // SQL Server: .WithTablesInScopeHint("nolock")
.Join<ISimpleEntity>((c, s) => c.Id == s.Id)
.CreateUpdateJoinBuilder()
.Set(p => p.Item1.String, "x")
.ToSql();
-- SQL Server: a WITH (...) clause on each physical table
update [t1] set t1.somestring = @p0 from complex_entity with (nolock) as [t1] join simple_entity with (nolock) as [t2] on t1.id = t2.id
-- PostgreSQL: one statement-level comment immediately after UPDATE (the same for DELETE)
update /*+ SeqScan(t1) */ complex_entity as "t1" set somestring = @p0 from simple_entity as "t2" where t1.id = t2.id
On PostgreSQL/MySQL/MariaDB the scope hint — together with any join or subquery hints collected for the
statement — is emitted once as a /*+ ... */ comment immediately after the DELETE/UPDATE keyword.
For a CTE-backed DML statement the hoisted WITH prefix is placed before the statement while the
comment stays after the DML verb, matching how the SELECT path places it after SELECT in a
WITH … SELECT. SQLite and ClickHouse reject a nonempty scope hint with NotSupportedException,
consistently with SELECT; a joined DELETE/UPDATE is itself unsupported on those providers, so their
existing capability error is unchanged. Hint-free DML stays byte-identical.
ClickHouse query modifiers
ClickHouse exposes four query-level modifiers that are not hints: Final(), PreWhere(predicate) and
Settings(("key", "value"), ...) are dedicated builder methods, while the Sample(ratio[, offset])
modifier is a per-query source option set in From. They are ClickHouse-only and ship as extension
methods in the nextorm.clickhouse package, so a file that uses them needs
using NextORM.ClickHouse;. Every other provider and the in-memory context throw NotSupportedException.
var rows = dataContext.From<IComplexEntity>(o => o.Sample(0.1, 0.5))
.Final()
.PreWhere(c => c.Int > 0)
.Where(c => c.Boolean == true)
.Select(c => new { c.Id, c.Int })
.Settings(("max_threads", "2"))
.ToList();
select id, nullableint
from complex_entity final sample 0.1 offset 0.5
prewhere (nullableint > 0)
where (b = true)
settings max_threads = 2
Settings is the trailer of the clause order: it is emitted after LIMIT ... BY and after
LIMIT/OFFSET, and ClickHouse rejects a LIMIT placed after SETTINGS (server Code 62). A scalar
terminal adds paging — First/FirstOrDefault render limit 1, Single/SingleOrDefault render
limit 2 — so the settings clause follows it:
var count = dataContext.From<IComplexEntity>()
.Settings(("max_threads", "2"))
.Select(c => SqlFunctions.Sql.count())
.First();
select toInt32(count(*))
from complex_entity
limit 1
settings max_threads = 2
Final() forces the merge of a ReplacingMergeTree/CollapsingMergeTree before the read; Sample reads a
fraction [0, 1] of the rows; PreWhere filters before the regular WHERE so the read can skip other
columns; Settings appends a trailing clause whose values are rendered verbatim (only pass trusted
literals). FINAL and PREWHERE require a table engine that supports them — the Memory engine rejects
both.
Limitations
- Locking table hints on the query's
FROMtable are rendered byWithTableHint; useJoinOptions.WithJoinTableHintfor a chosen join, orWithTablesInScopeHintto cover every physical table. - Concatenating a hinted command with a set operation (
Union,Intersect, ...) is not guarded against; the hint travels to the branch it was attached to and should be avoided there.
See also
- Joins -
CrossApply/OuterApply. - CTE -
maxRecursionand the SQL Serveroption (maxrecursion n)clause. - Queries and projections
Source: src/nextorm.core/Query/Commands/QueryCommand.TResult.cs (Hint),
src/nextorm.core/DataContext/Sql/FromOptions.cs (WithTableHint/WithIndex/WithoutIndex),
src/nextorm.core/Builders/JoinOptions.cs (WithJoinTableHint/WithJoinHint), src/nextorm.core/DataContext/Sql/FromOptions.cs (WithSubQueryHint), src/nextorm.core/Builders/Entity/EntityBuilder.cs (WithTablesInScopeHint),
src/nextorm.core/DataContext/Dialect/ISqlDialect.cs (SupportsQueryHints / RenderQueryHints),
src/nextorm.sqlserver/SqlServerDialect.cs, src/nextorm.postgres/PostgresDialect.cs,
src/nextorm.mysql/MySqlDialect.cs (MariaDB inherits).