The dynamic type in C#: run-time binding through the DLR and call-site caches, comparison with object and var, the costs (errors at run time, performance, trimming and AOT), and the narrow cases where it is still justified.
dynamic tells the compiler to skip type checking and resolve members, operators and overloads at run time. Under the hood the variable is just object; the compiler emits call sites that use the C# runtime binder on the DLR, which caches the binding for each run-time type. Compared with var (static inference) and object (static, needs casts), dynamic moves errors to run time as RuntimeBinderException, removes IntelliSense, is slower, and does not work with Native AOT. I only use it for COM or Office interop, scripting hosts or quick prototypes; for JSON I'd use JsonNode or typed records.
dynamic tells the compiler to skip static type checking for operations on that expression and defer member lookup to run time, through the Dynamic Language Runtime (DLR). The variable is still stored as object; the difference is that calls on it are compiled into call sites that bind at run time. I explain how that binding works, how dynamic differs from object and var, and the narrow cases where it is reasonable.
dynamic vs object vs varvar |
object |
dynamic |
|
|---|---|---|---|
| Type decided | Compile time, inferred | Compile time, object |
Compile time object, operations bound at run time |
| Member access checked | At compile time | Only object members |
At run time |
| IntelliSense | Full | object members only |
None |
| Errors | Compile errors | Compile errors (need casts) | RuntimeBinderException at run time |
| Performance | Normal | Casts / boxing | Call-site caching; first call very slow, later calls slower than static |
| Typical use | Everywhere | Polymorphic containers | Interop, dynamic data |
var a = "hello"; a.Length; // string, checked at compile time
object b = "hello"; b.Length; // compile error: object has no Length
dynamic c = "hello"; c.Length; // compiles; resolved at run time -> 5
c.Lenght; // compiles; throws RuntimeBinderException
For c.Length, the compiler emits a call site (CallSite<Func<CallSite, object, object>>) with a binder from Microsoft.CSharp.RuntimeBinder that knows C#'s rules.
c (here string) and applies C# member lookup and overload resolution as if the static type were that runtime type.string"), compiles it to a delegate, and stores it in the call site's cache.IDynamicMetaObjectProvider (ExpandoObject, DynamicObject, IronPython objects), the object itself decides how to bind (e.g. look up a key in a dictionary).IDispatch.The first call to each call site costs milliseconds (reflection, expression compilation); cached calls are much faster but still several times slower than a static call, and they allocate when value types are boxed.
dynamic is reasonableobject everywhere and optional parameters abound (excel.Cells[1, 1].Value = "x";). This was the original motivation in C# 4.ExpandoObject / DynamicObject for genuinely dynamic shapes, e.g. a templating engine where users define fields.Handle((dynamic)message) picks the overload by runtime type — a quick visitor substitute, but pattern matching is usually clearer.System.Text.Json, or JsonNode/JsonElement for unknown shapes. System.Text.Json deliberately does not support dynamic the way Newtonsoft's JObject did.dynamic produces trimming warnings and generally fails in NativeAOT.A service consumed a partner's JSON webhook with dynamic payload = JsonConvert.DeserializeObject(body); and read payload.order.total. When the partner renamed total to amount, nothing failed at compile time; at run time payload.order.total returned null from the JObject, which became 0 after conversion, and orders were recorded with zero value until finance noticed. We replaced it with a typed DTO and System.Text.Json with required properties and JsonUnmappedMemberHandling.Disallow, so a schema change fails loudly at the boundary, and added contract tests against the partner's published samples.
The interviewer checks type-system judgement and awareness of modern deployment constraints.
Green flags
var, object and dynamic.Red flags
dynamic routinely for JSON or to avoid casts.dynamic is the same as var.RuntimeBinderException.dynamic d = GetSomething();
int n = d.Count + 1;
becomes, roughly:
static CallSite<Func<CallSite, object, object>> s_getCount;
static CallSite<Func<CallSite, object, int, object>> s_add;
static CallSite<Func<CallSite, object, int>> s_convert;
s_getCount ??= CallSite<...>.Create(Binder.GetMember(CSharpBinderFlags.None, "Count", typeof(Program), ...));
s_add ??= CallSite<...>.Create(Binder.BinaryOperation(..., ExpressionType.Add, ...));
s_convert ??= CallSite<...>.Create(Binder.Convert(..., typeof(int), ...));
object count = s_getCount.Target(s_getCount, d);
object sum = s_add.Target(s_add, count, 1);
int n = s_convert.Target(s_convert, sum);
Every dynamic operation — member access, invocation, operator, conversion, indexer — is a separate call site. The dynamic type itself is just object marked with [Dynamic] in metadata.
Target delegate, rewritten to the most recently successful rule; if its type test passes, it executes immediately.Polymorphic call sites that see many different types degrade, because the cache keeps missing and recompiling — which is why dynamic in a loop over heterogeneous objects can be very slow.
Microsoft.CSharp.dll contains a slimmed-down version of C# semantic analysis so dynamic calls behave like static C#: overload resolution, implicit conversions, operator lookup, accessibility (based on the calling context type passed to the binder). This is why:
dynamic — the binder does not know which using directives were in scope. d.Where(...) fails at run time; call Enumerable.Where(d, ...) instead.dynamic from another assembly fail, because anonymous types are internal.IDynamicMetaObjectProvider, ExpandoObject and DynamicObjectObjects implementing IDynamicMetaObjectProvider supply a DynamicMetaObject that tells the DLR how to bind operations on them:
ExpandoObject stores members in an internal dictionary and implements IDictionary<string, object?> and INotifyPropertyChanged. expando.Name = "Ada" adds a key.DynamicObject is a base class with overridable TryGetMember, TrySetMember, TryInvokeMember — handy for wrappers over dictionaries, configuration or REST resources.| Operation | Approx. cost |
|---|---|
| Static property access | ~0.3 ns (inlined) |
Reflection PropertyInfo.GetValue |
~20–50 ns (faster with .NET 8+ reflection improvements) |
| Dynamic call, cached, monomorphic | ~10–30 ns plus boxing |
| Dynamic call, first time at a call site | ~1–10 ms (binder + expression compilation) |
First-call cost matters for cold starts (Azure Functions, serverless) and for code paths executed once per request with many call sites.
The C# runtime binder needs reflection over arbitrary types and System.Linq.Expressions compilation. Trimming can remove the members you call dynamically (they have no static references), and NativeAOT cannot JIT-compile expression trees (they fall back to an interpreter, if they work at all). The SDK emits trim warnings (IL2026) for dynamic usage. ASP.NET Core and System.Text.Json steer toward source-generated, statically typed code for this reason.
| Need | Prefer |
|---|---|
| Unknown JSON shape | JsonNode (mutable DOM) or JsonDocument/JsonElement (read-only, pooled) |
| Runtime type dispatch | Pattern matching switch, visitor pattern, dictionary of handlers |
| Calling members by name | Reflection with cached delegates, or source generators |
| Generic numeric code | Generic math (INumber<T>) instead of dynamic arithmetic |
| Configurable data shapes | Dictionary<string, JsonElement>, typed property bags |
| Office automation | Still dynamic (or the Open XML SDK to avoid COM entirely) |
dynamic in ILSpy (with "decompile dynamic" turned off) and inspect the call sites.dynamic list and read the RuntimeBinderException.dynamic with <PublishAot>true</PublishAot> and read the warnings.using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CSharp.RuntimeBinder;
namespace Types;
public sealed record PartnerOrder(string OrderId, decimal Total);
public static class DynamicDemo
{
public static void Main()
{
// var: static inference
var text = "hello";
Console.WriteLine(text.Length);
// object: static, needs a cast
object boxed = "hello";
Console.WriteLine(((string)boxed).Length);
// dynamic: bound at run time
dynamic d = "hello";
Console.WriteLine(d.Length); // works
try
{
Console.WriteLine(d.Lenght); // typo compiles, fails at run time
}
catch (RuntimeBinderException ex)
{
Console.WriteLine($"Binder: {ex.Message}");
}
// ExpandoObject: a legitimate dynamic use (property bag)
dynamic bag = new ExpandoObject();
bag.Name = "Ada";
bag.Score = 42;
foreach (var (key, value) in (IDictionary<string, object?>)bag)
Console.WriteLine($"{key} = {value}");
// Preferred for JSON: typed records, or JsonNode for unknown shapes
const string json = """{ "orderId": "A-1", "total": 99.50 }""";
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
PartnerOrder? order = JsonSerializer.Deserialize<PartnerOrder>(json, options);
Console.WriteLine(order);
JsonNode? node = JsonNode.Parse(json);
decimal? total = node?["total"]?.GetValue<decimal>();
Console.WriteLine(total);
}
}
dynamic to avoid writing typesWhat happens: API responses or database results are handled as dynamic because a DTO felt like boilerplate.
Why it is wrong: typos and schema changes become runtime failures; refactoring tools cannot find usages.
Do instead: write a record (often one line), or use JsonNode when the shape truly varies.
dynamic with varWhat happens: developers think var is "like dynamic" or use dynamic where var was meant.
Why it is wrong: var is static and checked; dynamic defers all checks to run time.
Do instead: use var for inference; dynamic only for genuine late binding.
null/default from dynamic JSONWhat happens: payload.customer.email returns null because of a renamed or missing field in a JObject.
Why it is wrong: no exception at the boundary; bad data flows into the system.
Do instead: deserialise into typed models with required properties and validation; fail fast on unknown or missing fields.
dynamicWhat happens: dynamicList.Select(x => x.Id) or dynamicString.IsBlank().
Why it is wrong: the runtime binder does not see using directives, so extension methods are not found (and lambdas cannot be bound).
Do instead: cast to a static type first or call the static method explicitly.
dynamic in hot pathsWhat happens: dynamic dispatch inside loops processing thousands of items. Why it is wrong: binding, cache checks and boxing are much slower than static calls; polymorphic sites re-bind repeatedly. Do instead: use interfaces, generics or pattern matching; cache delegates if reflection is necessary.
dynamic in trimmed or NativeAOT appsWhat happens: a library using dynamic is consumed by a NativeAOT or trimmed app.
Why it is wrong: members called only dynamically may be trimmed, and the binder needs runtime code generation; failures appear only at run time.
Do instead: avoid dynamic in libraries, heed IL2026 warnings, and use source generators.
dynamic in public APIsWhat happens: methods return or accept dynamic.
Why it is wrong: consumers lose IntelliSense and compile-time checks, and the contract is undocumented.
Do instead: expose typed contracts; keep dynamic code behind a typed adapter.
What happens: a library returns an anonymous object typed as object, and the caller uses dynamic to read it.
Why it is wrong: anonymous types are internal, so binding fails from another assembly.
Do instead: return a named record.
RuntimeBinderExceptionWhat happens: dynamic code is wrapped in try/catch that ignores binder errors.
Why it is wrong: schema or contract changes are silently ignored.
Do instead: let them surface, or better, remove the dynamic code path.
dynamic for multiple dispatch in complex domainsWhat happens: Handle((dynamic)evt) used as the core event-dispatch mechanism.
Why it is wrong: missing overloads fail only at run time; performance and debuggability suffer.
Do instead: use pattern matching, a handler registry in DI (IEventHandler<T>), or a visitor.
Seniors treat dynamic as an interop escape hatch with clear boundaries.
| Factor | Impact of dynamic |
|---|---|
| Correctness | errors move from compile time to run time |
| Maintainability | no rename refactoring, no find-references |
| Performance | first-call binding cost, slower cached calls, polymorphic misses |
| Deployment | blocks Native AOT and safe trimming |
| Security | no direct risk, but untyped input handling often skips validation |
dynamic in a small adapter (for example an Office automation wrapper) and expose a typed interface to the rest of the system.dynamic in interviews, showing you know the DLR call-site cache and the AOT limitation is a strong senior signal.Type inference lets the compiler work out a type from context. var takes the static type of the initialiser, so the variable is still strongly typed; it is required for anonymous types. Target-typed new() and collection expressions (List<int> xs = [1, 2]) go the other way, taking the type from the declaration. Generic arguments are inferred from method arguments, and since C# 10 lambdas have a natural type. I use var when the type is obvious from the right-hand side and write the type when it is not, or when the exact type changes behaviour, like IQueryable<T> versus IEnumerable<T> in EF Core.
Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.
How to tell it (STAR)
**S:** A partner changed their webhook payload and our integration [at company] started dropping orders. **T:** I needed to restore the flow and stop it happening again. **A:** I found the payloads in the dead-letter queue, saw the renamed field, replaced the dynamic parsing with typed records that accepted both versions, replayed the failed messages and added contract tests. **R:** All [number] delayed orders were processed the same day, and the next partner change was caught in CI.
Last updated 30 Oct 2026.