📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is dynamic in C#? How does the DLR bind calls at run time, how does it differ from object and var, and when is it a reasonable choice?

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.

Mid-Level Theory Intermediate csharp dlr dynamic interop reflection types

🎯 Key points

  • dynamic is a static type that tells the compiler to skip member checks and bind operations at run time
  • At run time it is just object; the compiler emits call sites that use the C# runtime binder (Microsoft.CSharp) on top of the DLR
  • Call sites cache bindings per run-time type, so repeated calls are faster than the first but slower than static calls
  • Errors become RuntimeBinderException at run time instead of compile errors; no IntelliSense or refactoring support
  • var is compile-time inference (fully static); object is static and needs casts; dynamic defers all checks
  • dynamic flows: any expression involving a dynamic value is usually dynamic too, spreading weak typing
  • dynamic relies on reflection, so it does not work with Native AOT and is unsafe with trimming
  • Reasonable uses: COM/Office interop, IronPython-style scripting, ExpandoObject, quick prototypes; prefer generics, interfaces, JsonNode or source generators otherwise

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. dynamic vs object vs var

var 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

2. How the DLR binds a call

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.

  1. On first execution, the binder inspects the runtime type of c (here string) and applies C# member lookup and overload resolution as if the static type were that runtime type.
  2. It produces an expression tree for the operation plus a test ("if the target is a string"), compiles it to a delegate, and stores it in the call site's cache.
  3. Next time, the call site checks its cached rules (L0 single rule, L1 small cache, L2 binder cache); if the test matches it runs the compiled delegate directly.
  4. If the object implements IDynamicMetaObjectProvider (ExpandoObject, DynamicObject, IronPython objects), the object itself decides how to bind (e.g. look up a key in a dictionary).
  5. COM objects are bound through 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.

3. When dynamic is reasonable

  • COM interop with Office automation, where interop assemblies expose object everywhere and optional parameters abound (excel.Cells[1, 1].Value = "x";). This was the original motivation in C# 4.
  • Interop with dynamic languages (IronPython, scripting engines).
  • ExpandoObject / DynamicObject for genuinely dynamic shapes, e.g. a templating engine where users define fields.
  • Double dispatch / multiple dispatch in small, cold code: Handle((dynamic)message) picks the overload by runtime type — a quick visitor substitute, but pattern matching is usually clearer.
  • Quick prototypes, tests or LINQPad scripts.

4. When it is not

  • JSON handling in modern .NET: use typed models with System.Text.Json, or JsonNode/JsonElement for unknown shapes. System.Text.Json deliberately does not support dynamic the way Newtonsoft's JObject did.
  • Avoiding a DTO: "I didn't want to write a class" is not a reason — records take one line.
  • Hot paths: binding and boxing costs.
  • Trimmed / NativeAOT apps: the C# runtime binder relies on reflection and runtime code generation, which are not supported or are unsafe under trimming and AOT. dynamic produces trimming warnings and generally fails in NativeAOT.
  • Public APIs: callers lose all compile-time help, and refactoring tools cannot follow usages.

5. A production example

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.

🔍 What the interviewer is checking

The interviewer checks type-system judgement and awareness of modern deployment constraints.

Green flags

  • Clearly separates var, object and dynamic.
  • Explains run-time binding, the DLR and call-site caching.
  • Names legitimate uses (COM, ExpandoObject, scripting) and better alternatives for JSON.
  • Mentions Native AOT and trimming incompatibility.

Red flags

  • Uses dynamic routinely for JSON or to avoid casts.
  • Thinks dynamic is the same as var.
  • Unaware of RuntimeBinderException.

📚 Learn in depth

1. What the compiler emits

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.

2. The three-level cache

  • L0: the call site's Target delegate, rewritten to the most recently successful rule; if its type test passes, it executes immediately.
  • L1: a small per-call-site list of recently used rules (handles a few runtime types at one call site, e.g. polymorphic objects).
  • L2: a binder-level cache shared across call sites with the same binder.
  • Miss: the binder runs C# semantic analysis against the runtime types, builds an expression tree with a restriction (type test), compiles it, and updates the caches.

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.

3. The C# runtime binder replicates the compiler

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:

  • Private members are accessible if the dynamic call is made from inside the declaring class.
  • Extension methods are not available on dynamic — the binder does not know which using directives were in scope. d.Where(...) fails at run time; call Enumerable.Where(d, ...) instead.
  • Lambdas cannot be passed directly as arguments to dynamic calls (the binder cannot infer a delegate type); cast them first.
  • Anonymous types accessed via dynamic from another assembly fail, because anonymous types are internal.

4. IDynamicMetaObjectProvider, ExpandoObject and DynamicObject

Objects 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.

5. Performance numbers (order of magnitude)

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.

6. Trimming and NativeAOT

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.

7. Modern alternatives

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)

Try it yourself

  1. Decompile a method using dynamic in ILSpy (with "decompile dynamic" turned off) and inspect the call sites.
  2. Benchmark static, reflection and dynamic property access with BenchmarkDotNet, including a first-call measurement.
  3. Try calling a LINQ extension method on a dynamic list and read the RuntimeBinderException.
  4. Publish a small console app using dynamic with <PublishAot>true</PublishAot> and read the warnings.

💻 Code

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);
    }
}

⚠ Common mistakes

1. Using dynamic to avoid writing types

What 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.

2. Confusing dynamic with var

What 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.

3. Silent null/default from dynamic JSON

What 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.

4. Calling extension methods on dynamic

What 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.

5. Using dynamic in hot paths

What 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.

6. Using dynamic in trimmed or NativeAOT apps

What 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.

7. Exposing dynamic in public APIs

What 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.

8. Accessing anonymous types dynamically across assemblies

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.

9. Swallowing RuntimeBinderException

What 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.

10. Using dynamic for multiple dispatch in complex domains

What 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.

🏛 Senior insight: trade-offs

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
  • Isolate dynamic in a small adapter (for example an Office automation wrapper) and expose a typed interface to the rest of the system.
  • For integration contracts, typed models plus contract tests (Pact, schema validation) catch partner changes before production.
  • When asked about dynamic in interviews, showing you know the DLR call-site cache and the AOT limitation is a strong senior signal.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate compares dynamic with var. Why: Checks that the candidate clearly separates var inference from dynamic.
    How to answer

    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.

  2. When: The candidate says dynamic is object at run time. Why: dynamic stores values as object, so value types are boxed.
    How to answer

    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>.

🛠 Real-world problems: how you handle them

Partner renamed a JSON field and dynamic parsing failed in production
Situation
A logistics integration consumed partner order webhooks and parsed them with JsonConvert.DeserializeObject<dynamic>, reading data.order.totalAmount.
Signals
RuntimeBinderException spikes in Application Insights, failed webhook deliveries and the partner retrying, orders missing from the warehouse system.
Investigation
Inspected failed payloads stored in the dead-letter queue, found totalAmount renamed to total in the partner's new API version, and confirmed the dynamic code compiled against any shape.
Resolution
Replaced dynamic parsing with versioned typed records and System.Text.Json, handled both field names during the transition, and replayed the dead-lettered messages.
Prevention
Consumer contract tests against the partner's published schema in CI, alerts on dead-letter queue depth, and a rule against dynamic in integration code.

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.

↩ Usually asked after

More in Types

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me