dotnet/efcore · error · InvalidOperationException

FromSqlNonComposable

FromSqlNonComposable

Error message

'FromSql' or 'SqlQuery' was called with non-composable SQL and with a query composing over it. Consider calling 'AsEnumerable' after the method to perform the composition on the client side.

What it means

Thrown by CheckComposableSql when a FromSqlInterpolated/FromSqlRaw SQL string contains a SQL line comment ('--') that is never terminated by a newline. Because EF needs to embed the raw SQL as a subquery (it wraps it in '(...) AS t') to compose LINQ operators over it, it must verify the leading token is composable; an unterminated comment makes that impossible to determine. This only triggers when additional LINQ operators are composed over the FromSql query (forcing VisitFromSql/CheckComposableSql to run).

Source

Thrown at src/EFCore.Relational/Query/QuerySqlGenerator.cs:567

    /// <summary>
    ///     Checks whether a given SQL string is composable, i.e. can be embedded as a subquery within a
    ///     larger SQL query.
    /// </summary>
    /// <param name="sql">An SQL string to be checked for composability.</param>
    /// <exception cref="InvalidOperationException">The given SQL isn't composable.</exception>
    protected virtual void CheckComposableSql(string sql)
    {
        var span = sql.AsSpan().TrimStart();

        while (true)
        {
            // SQL -- comment
            if (span.StartsWith("--"))
            {
                var i = span.IndexOf('\n');
                span = i > 0
                    ? span[(i + 1)..].TrimStart()
                    : throw new InvalidOperationException(RelationalStrings.FromSqlNonComposable);
                continue;
            }

            // SQL /* */ comment
            if (span.StartsWith("/*"))
            {
                var i = span.IndexOf("*/");
                span = i > 0
                    ? span[(i + 2)..].TrimStart()
                    : throw new InvalidOperationException(RelationalStrings.FromSqlNonComposable);
                continue;
            }

            break;
        }

        CheckComposableSqlTrimmed(span);
    }

View on GitHub (pinned to dbf9771522)

Solutions

  1. Ensure any '--' line comment is followed by a newline ('\n') so EF can skip past it.
  2. Remove the inline comment from the raw SQL string.
  3. If the SQL is genuinely non-composable (e.g. a stored proc call), call AsEnumerable()/AsAsyncEnumerable() immediately after FromSql so composition happens client-side and CheckComposableSql is never invoked.
  4. Refactor the comment into a C# comment above the FromSql call instead of inside the SQL literal.

Example fix

// before
var q = db.Blogs.FromSqlRaw("-- top secret\nSELECT * FROM Blogs").Where(b => b.Id > 0);
// the above throws only if the literal lacks a newline; fix by terminating the comment:
var q = db.Blogs.FromSqlRaw("-- top secret\r\nSELECT * FROM Blogs").Where(b => b.Id > 0);
// or move the note out of SQL:
// top secret
var q = db.Blogs.FromSqlRaw("SELECT * FROM Blogs").Where(b => b.Id > 0);
Defensive patterns

Strategy: validation

Validate before calling

static bool IsComposableSql(string sql)
{
    var i = 0;
    while (i < sql.Length)
    {
        if (sql[i] == '-' && i + 1 < sql.Length && sql[i + 1] == '-')
        {
            var nl = sql.IndexOf('\n', i);
            if (nl < 0) return false; // unterminated line comment
            i = nl + 1;
        }
        else if (sql[i] == '/' && i + 1 < sql.Length && sql[i + 1] == '*')
        {
            var close = sql.IndexOf("*/", i, StringComparison.Ordinal);
            if (close < 0) return false; // unterminated block comment
            i = close + 2;
        }
        else { i++; }
    }
    return true;
}

if (!IsComposableSql(rawSql)) rawSql += "\n"; // or call AsEnumerable() instead

Try / catch

try { var r = db.Blogs.FromSqlRaw(sql).Where(b => b.Id > 0).ToList(); }
catch (InvalidOperationException ex) when (ex.Message.Contains("non-composable SQL"))
{
    // fall back to client-side composition
    var r = db.Blogs.FromSqlRaw(sql).AsEnumerable().Where(b => b.Id > 0).ToList();
}

Prevention

When it happens

Trigger: Calling FromSqlRaw/FromSqlInterpolated with a string that begins with or contains a '--' comment with no trailing '\n', then chaining a LINQ operator (e.g. .Where(), .Select(), .ToList()) so EF must treat it as a subquery. Example: context.Blogs.FromSqlRaw("-- my query SELECT * FROM Blogs").ToList() with the comment lacking a final newline.

Common situations: Building SQL strings dynamically and concatenating a comment header without a trailing newline; reading SQL from a resource/external source where trailing whitespace was stripped; copy-pasting a commented query fragment; using string interpolation that drops the terminating newline.

Related errors


AI-assisted analysis of dotnet/efcore@dbf9771522 (2026-08-06). Data as JSON: /api/errors/bea85978c17c6486. Report an issue: GitHub.