C# Language Versions, the Compiler and Preview Features
Objective
A C# project has two separate version numbers that people constantly mix up: the language version (what syntax the compiler accepts) and the target framework (which runtime and libraries your code runs on). The compiler ships with the SDK, so a .NET 10 SDK can compile C# 14 code that targets .NET 8. But only some C# features are pure compiler work. Others need a type or a runtime capability that older frameworks do not have. This concept explains how the default language version is chosen, how to override and inspect it, which C# 14 features survive on an old target framework (checked by compiling, not guessed), and how preview features are opted into.
Use Cases
- Upgrading to the .NET 10 SDK while the application still targets .NET 8, and
wanting
fieldor null-conditional assignment today. - Reading a compile error such as "Feature 'x' is not available in C# 12.0" and
knowing whether to change
LangVersionor the target framework. - Finding out which language version a build is really using on a CI machine.
- Trying a preview compiler feature in a throwaway project without leaking it into production code.
Deep Dive
The default follows the target framework, not the SDK
When you do not set LangVersion, the compiler picks a default from the target
framework, so that a project compiled by a newer SDK keeps the language it was
designed with. This is easy to observe with the same source file:
plaintext<!-- p.csproj, only TargetFramework changes --> <TargetFramework>net8.0</TargetFramework> <!-- default C# 12 --> <TargetFramework>net9.0</TargetFramework> <!-- default C# 13 --> <TargetFramework>net10.0</TargetFramework> <!-- default C# 14 -->
Asking MSBuild for the property on a net8.0 project with the 10.0.100 SDK
returns the effective value:
plaintext$ dotnet msbuild -getProperty:LangVersion 12.0 (net10.0 project: 14.0)
Another trick is to force a diagnostic that prints the compiler and language
version. A file that contains only #error version fails with:
plaintexterror CS8304: Compiler version: '5.0.0-2.25523.111 (b0f34d51)'. Language version: 12.0.
(net8.0 project, default settings; the same file in a net10.0 project reports
14.0.) The compiler version belongs to the SDK, the language version belongs to
the project. Microsoft recommends not setting LangVersion at all and raising
the target framework instead, because the default is the combination the
language and the libraries were tested together.
Overriding LangVersion
LangVersion accepts a number (13.0, 14), default/latestMajor (newest
major version this compiler supports, ignoring the target framework),
latest (newest released version, including minor ones) and preview. Two
rules matter in practice:
- The value is capped by the compiler. With the 10.0.100 SDK, setting
LangVersionto15fails withCS1617: Invalid option '15', and a .NET 9 SDK (9.0.307) rejects14the same way. A newer language needs a newer SDK. latestanddefaultare machine-dependent. The documentation warns againstlatestbecause two machines with different SDKs build different languages. Pin an explicit number if you must override, or better, pin the SDK withglobal.json.
What works on an old target framework
Setting <LangVersion>14</LangVersion> on net8.0 and net9.0 projects and
compiling one small program per feature, with the 10.0.100 SDK, gave this
result (without LangVersion each of the C# 14 rows fails with CS9202 or
CS9260, "not available in C# 12.0/13.0"):
| Feature | net8.0 | net9.0 | net10.0 |
|---|---|---|---|
field keyword in accessors |
works | works | works |
Null-conditional assignment a?.B = x |
works | works | works |
nameof(List<>) |
works | works | works |
Lambda parameter modifiers (out x) => ... |
works | works | works |
Extension blocks extension(string s) { ... } |
works | works | works |
| Partial constructors | works | works | works |
User-defined compound operators (operator +=) |
works | works | works |
params ReadOnlySpan<int> (C# 13) |
works | works | works |
allows ref struct (C# 13) |
CS9240 runtime lacks by-ref-like generics |
works | works |
System.Threading.Lock (type, .NET 9) |
CS0234, type missing |
works | works |
The pattern is the rule of thumb: syntax that the compiler can lower into code
that already runs on the old runtime works everywhere, while a feature that
depends on a new runtime capability (allows ref struct needs runtime support
for ref-like types as generic arguments) or a new library type (Lock) fails
on the older framework even with the newest LangVersion. The language
documentation says it directly: each feature has its own minimum API or runtime
requirement.
One feature deserves a note. The implicit span conversions of C# 14 are a
language change, so an extension method written for ReadOnlySpan<int> can be
called on an int[] in a net9.0 project only after you raise the language
version:
csharpint[] arr = [1, 2, 3];
Console.WriteLine(arr.Twice()); // LangVersion 14: 6
// default C# 13: error CS1929, 'int[]' does not contain a definition for 'Twice'
static class E { public static int Twice(this ReadOnlySpan<int> s) => s.Length * 2; }Preview features and EnablePreviewFeatures
Two different mechanisms are both called "preview" and they do different things:
<LangVersion>preview</LangVersion>tells the compiler to accept syntax from the next, unreleased language version. It compiled fine on the 10.0.100 SDK for a program that used nothing new. Preview syntax can change or disappear and is not supported in production.<EnablePreviewFeatures>true</EnablePreviewFeatures>opts in to preview APIs and runtime features, the ones marked with[RequiresPreviewFeatures]. Without it, using such a type produces warningCA2252("requires opting into preview features"); with it, the code runs.
csharpusing System.Runtime.Versioning;
Foo.Bar(); // CA2252 warning unless EnablePreviewFeatures is true
[RequiresPreviewFeatures] static class Foo { public static void Bar() => Console.WriteLine("preview api"); }You can also build with a newer SDK than the runtime you ship on: a project can
target net10.0 and use a newer compiler through LangVersion, as long as the
features used are compiler-only, and the SDK is pinned in global.json so the
team agrees on it.
Standard versus implementation
C# is specified separately from Roslyn, the compiler that implements it. The
ECMA-334 editions linked from Microsoft Learn stop at the C# 7 era, and later
versions are described by the Microsoft-maintained language specification and by
the feature proposals in the csharplang repository. In
practice Roslyn is the reference: a feature exists when Roslyn ships it, and
each "What's new in C# N" page is the proof for a version claim.
Trade-offs
- Raising
LangVersionon an old target framework buys syntax, not libraries. Most C# 14 syntax works onnet8.0, but a feature that needs a runtime or library capability fails at compile time or, worse, only on some machines. Test with the lowest framework you support.csharpstatic T Id<T>(T t) where T : allows ref struct => t; // net8.0: CS9240 - Leaving
LangVersionunset is the safe default but feels slow. You get C# 12 on a .NET 8 project even with the .NET 10 SDK, and you must raise the target framework to move on. The upside is that every contributor sees the same language regardless of installed SDK. latestandpreviewmake builds depend on the machine. Two developers with different SDKs can disagree on whether the code compiles. Pin the SDK inglobal.jsonif you use them.plaintext{ "sdk": { "version": "10.0.100", "rollForward": "disable" } }- Preview syntax is a bet. It can change before release, and code written for it may stop compiling. Keep it in exploration projects.
- Multi-targeting multiplies the test matrix. A library that targets
net8.0andnet10.0with a highLangVersionmust be compiled and tested for each target, because a feature can compile for one target and fail for another.