The dynamic keyword tells the compiler "don't check this type for me, figure it out at runtime." It's a static type in the language, but every operation against it (method calls, property access, arithmetic) is resolved by the Dynamic Language Runtime when the line of code actually executes. This chapter is about why C# has that escape hatch, where it is useful, and the specific ways it can backfire.
A variable declared dynamic looks like any other variable, but the compiler treats it specially. Member access against it doesn't get verified at compile time. Instead, the compiler emits a call site that, at runtime, asks the DLR (the Dynamic Language Runtime, a small runtime layer that sits on top of the CLR) to figure out what the call means for the actual object behind the reference.
The same variable goes from double to string to int[] without the compiler complaining. The member calls (ToLower(), Length) aren't checked when the program is built; they're checked when each line runs. And every operation routes through the DLR, which inspects the runtime type, finds the matching member, and invokes it.
The first dynamic call against a given expression is slow. The DLR has to build a call site, bind the operation to the runtime type, generate (and cache) IL for the binding, and only then invoke. Subsequent calls against the same call site reuse the cached binding and are much faster, often within a small multiple of a direct call. The first hit can still be hundreds of times slower than the equivalent static call.
These three keywords get confused a lot, and the differences are real. object is the root of the type system: every type inherits from it, but you can only access members declared on object itself (like ToString() or GetHashCode()). var is a compile-time convenience: the compiler infers the actual type from the initializer, and from that point on the variable behaves as that type. dynamic is a static type in the language, but the compiler defers member resolution to runtime.
All three lines end up calling string.ToLower(), but the path to get there is different. The object version requires a cast to a known type before you can use any string-specific member. The var version is a string with shorter syntax. The dynamic version skips compile-time checking entirely and asks the DLR to find ToLower on whatever the runtime type turns out to be.
| Keyword | Type checked at | Member access without cast | IntelliSense | Common use |
|---|---|---|---|---|
object | Compile time | No, must cast first | Yes, but only object members | Storing any reference, generic root |
var | Compile time (inferred) | Yes, full IntelliSense | Yes | Local variable shorthand when type is obvious |
dynamic | Runtime | Yes, but unchecked | No | Loose JSON, COM, duck typing |
| Reflection | Runtime, manually | No, you call GetMethod etc. yourself | No | Tooling, frameworks, scanning types |
The character of dynamic shows up in how errors surface. Static C# catches most mistakes at compile time: misspelled method names, wrong argument counts, missing properties. With dynamic, those same mistakes compile cleanly and only throw when the offending line actually runs.
The first two property accesses succeed because the anonymous object has Id and Total. The third one compiles fine, but at runtime the DLR can't find CustomerName on the actual object and throws RuntimeBinderException. The same typo against a strongly-typed object would have been caught the moment you saved the file.
This is the core trade-off. dynamic buys flexibility (you can write code against shapes you don't know at compile time) at the price of safety (you find out about mistakes when the code runs, not when it builds).
When the compiler sees a member access against a dynamic, it emits a call site: a small piece of plumbing that wraps the operation. At runtime, the first time the site runs, the DLR asks the appropriate binder (the C# binder for normal C# objects, the COM binder for COM objects, the IronPython binder for Python objects, and so on) to bind the operation to the runtime type. The result is cached on the call site, keyed by the runtime type, so the next call against the same type can skip most of the work.
The diagram shows the two paths a member call can take. The static path is fully resolved at compile time; the compiler knows the target method and emits a direct IL call (or callvirt) instruction. The dynamic path stops at compile time after emitting a call site and lets the DLR do the work at runtime: look up the member on the runtime type, generate the binding, cache it, and invoke. The static path is straightforward and fast. The dynamic path is flexible but pays for it on the first call.
The first call against a fresh dynamic call site allocates the site, runs the binder, and generates code. Repeat calls against the same site (same runtime type, same operation) hit the cache and are much cheaper. If the runtime type changes from one call to the next, the cache misses and the binder runs again, so polymorphic call sites pay the binding cost over and over.
dynamic is useful in a handful of specific scenarios. Outside those, static typing is the better default.
Sometimes you receive JSON whose shape isn't worth modeling: a config blob, a webhook payload from a partner that changes occasionally, an admin tool that needs to peek at arbitrary fields. Defining a class for every variation is busywork. dynamic lets you read fields by name and move on.
Newtonsoft.Json.Linq.JObject (the runtime type produced by DeserializeObject) implements the dynamic protocol, so property access through dynamic walks into the parsed JSON tree. The same code with a typed model would need a Coupon class, possibly with attributes, and rebuilding it every time the JSON shape shifts. For a one-off script or a low-volume admin path, dynamic is faster to write and good enough.
The cost is real, though. There's no IntelliSense, a misspelled property name returns null instead of failing, and large-volume hot paths pay the DLR overhead on every field access. For a high-throughput API, use the typed model.
You have two unrelated types from two different libraries. Both have a GetName() method, but neither implements a common interface, and you can't add one (you don't own either library). With static C#, your options are an adapter wrapper or reflection. dynamic lets you call GetName() directly because the DLR only cares about whether the method exists at runtime.
The method PrintName accepts anything that exposes a GetName() method. If a caller passes a type without one, the DLR throws RuntimeBinderException at the point of the call. This is duck typing, the JavaScript or Python style, dropped into C#. It works, but it puts the validation burden on whoever runs the code instead of whoever compiles it.
The classic case is automating Microsoft Office or other COM components. COM APIs are full of variant types and optional arguments, and the traditional C# way of calling them involves piles of casts and Type.Missing placeholders. dynamic cleans that up: the COM binder handles the variant boxing and argument defaults transparently. This chapter doesn't go deeper than that. If you're writing Office automation or wrapping a legacy COM SDK, dynamic is the standard tool.
When you embed IronPython, Python.NET, or another DLR-aware host inside a .NET process, dynamic is the bridge between C# code and the scripted objects. The host returns objects whose shape isn't known at compile time, and dynamic lets C# call into them without manual reflection. This is a narrow scenario, and it's the one the DLR was originally designed for.
When you want an object whose properties aren't fixed at compile time (you're going to add or read fields by name dynamically), ExpandoObject is the standard tool. It lives in System.Dynamic, behaves like a property bag, and plays nicely with the DLR so you can use property syntax instead of dictionary indexing.
The same object first has three properties, then four. There's no class definition anywhere; the shape is built up at runtime. Internally, ExpandoObject is a Dictionary<string, object> dressed up with the dynamic protocol.
That dictionary nature is more than an implementation detail; it's the public contract. ExpandoObject implements IDictionary<string, object>, so you can iterate, check for keys, or remove properties.
The cast to IDictionary<string, object?> is what unlocks enumeration. Once you have it, the object behaves like any other dictionary. The dynamic view and the dictionary view both see the same backing storage; a property added through one is visible through the other.
ExpandoObject also implements INotifyPropertyChanged, which makes it usable as a data source for WPF, MAUI, or any binding framework that listens for property changes. Set a new value and any subscriber gets a notification, no matter that the property didn't exist five minutes earlier.
A realistic use case: a discount configuration object whose exact shape depends on a campaign type. Free-shipping discounts have one set of fields; percent-off discounts have another; bundle discounts have a third. Modeling all of them with a single class means a lot of nullable fields and "which fields apply to which type" rules. An ExpandoObject built from the campaign JSON skips the modeling entirely.
Each BuildDiscount call returns an object whose shape matches the campaign type. The Describe method doesn't care about the shape; it just iterates the dictionary view and prints what's there. (Note: the final output line is a quirk of how the array prints; in real code you'd format the array contents.)
ExpandoObject is useful when you want a property bag. For more elaborate dynamic behavior (lazy loading, computed properties, intercepting unknown method calls), you subclass DynamicObject and override the methods that the DLR calls when it can't find a member statically.
The three main overrides are:
TryGetMember(GetMemberBinder, out object?): called when someone reads a property.TrySetMember(SetMemberBinder, object?): called when someone writes a property.TryInvokeMember(InvokeMemberBinder, object?[]?, out object?): called when someone invokes a method.Each returns bool: true means "I handled it," false means "I don't have this member, let the DLR throw."
The following DynamicProduct lazy-loads property values from a dictionary, simulating a row pulled from a database where you don't want to load every column eagerly.
The first access to AverageRating runs the loader (incrementing the counter); the second access hits the cache and skips it. The DLR routes every unknown property access through TryGetMember, and the override decides whether to load, cache, or refuse.
If you ask for a property that has no loader, TryGetMember returns false, and the DLR raises RuntimeBinderException.
TrySetMember works the same way for writes, and TryInvokeMember for method calls. You can override any subset; the DLR only calls the ones you override and falls back to the default "no such member" behavior for the rest.
DynamicObject is more flexible than ExpandoObject but also more expensive per call, because each member access invokes a virtual method (TryGetMember, TrySetMember) that you implement in C#. For a hot path, a typed object with explicit properties is far faster. Use DynamicObject when the flexibility (lazy loading, computed members, proxy-style routing) is useful.
These three tools solve overlapping problems but with different trade-offs. Pick the one that matches your constraint.
| Tool | Type safety | When to use it | Performance |
|---|---|---|---|
| Generics | Full compile-time safety | You want one method or class to work across many types, and the type is known to the caller. | Effectively free; no runtime overhead beyond what the underlying code already does. |
| Reflection | None at the call site | You're scanning types at runtime, building tooling (serializers, ORMs, dependency injection), or working with metadata. | Slow per call; manual code generation or caching helps. |
| dynamic | None at the call site | You need to call members on objects whose shape isn't known at compile time, especially loose JSON, scripting, COM. | First call slow (binder setup), subsequent calls cached and faster than reflection. |
A sketch of the same operation in each style:
The dynamic version is shorter and, after the first call, faster than the reflection version because the binding is cached on the call site. Use reflection when you need the metadata itself (you're listing properties, reading attributes, building a serializer) or when you can't use the DLR (NativeAOT, trimming-sensitive deployments).
If a common interface is feasible, generics with an interface constraint beat both: full type safety, full IntelliSense, no DLR overhead. Use dynamic only when you've ruled out giving the involved types a common type.
One subtle thing about dynamic is that it's contagious. The result of any operation that has at least one dynamic operand is itself dynamic. The compiler propagates the dynamic type through arithmetic, method calls, indexing, even simple addition.
The runtime type of total is int (because subtracting two integers gives an integer), but the static type that the compiler sees is dynamic. From this point on, anything you do with total is also dynamic. The dynamic-ness spreads outward through the expression tree.
This matters in two ways. First, method return types: if a method takes a dynamic parameter, callers should know its return type is also commonly dynamic, and the dynamic-ness keeps propagating. Second, debugging: stepping through code where every local is dynamic is harder, because the debugger can't tell you the runtime type without evaluating expressions.
The fix when you want to seal the dynamic-ness back into a static type is an explicit cast.
The cast tells the compiler "I promise this is an int," and from that point on the variable is statically typed again.
A handful of issues affect code that uses dynamic without thinking through the consequences.
No IntelliSense. Tooling has nothing to suggest, because there's no static type to inspect. You lose autocomplete, refactoring help (renames don't update dynamic call sites), and most of the safety net Visual Studio or Rider gives you for static code. A misspelled property name compiles and only fails at runtime.
RuntimeBinderException. Every "method not found" or "property doesn't exist" error becomes a runtime exception instead of a compile error. The exception messages are decent but they only fire when the line runs. Code paths that aren't exercised by tests can hide these bugs indefinitely.
dynamic spreads through return types. A method like dynamic GetField(string name) => _data[name] looks innocent, but every call site that uses it sees a dynamic value, and the dynamic-ness keeps spreading through any expressions that touch that value. A single dynamic boundary can spread across a large part of a method without the author noticing.
Trimming and NativeAOT. The DLR depends on dynamic code generation (it builds and JITs the binding at runtime). NativeAOT and aggressive trimming both break that assumption. NativeAOT compilations refuse to support dynamic outright; trimmed apps can drop the binder code the DLR needs and crash at runtime. If your app targets ahead-of-time compilation (mobile apps, native Linux containers, single-file deployments with trimming), dynamic is off the table.
NativeAOT and trimmed deployments reject the DLR. A library that uses dynamic internally cannot be consumed safely from an AOT app. Source generators are the build-time alternative when you want the flexibility of code that adapts to types without paying the DLR cost or losing AOT compatibility.
Boxing. Every value type passed through dynamic is boxed: the DLR works with object-shaped operands, so an int going through a dynamic site becomes a boxed int on the heap. In hot paths, this adds allocation pressure on top of the binding overhead. For numeric inner loops, dynamic is the wrong tool by every measure.
A short heuristic for picking the appropriate tool:
| Situation | Tool |
|---|---|
| You know the type at compile time | Plain static C#. No question. |
| You want one method to work across many statically-known types | Generics, ideally with an interface constraint |
| You need to inspect type metadata (property names, attributes) | Reflection |
| You're calling a COM API or scripting host | dynamic |
| You're reading loose JSON in a low-throughput admin tool | dynamic, with caveats |
| You're reading loose JSON in a high-throughput API | Typed model with System.Text.Json source generators |
| You need a property bag whose shape is decided at runtime | ExpandoObject |
| You need dynamic behavior beyond a property bag (lazy loading, proxying) | Subclass DynamicObject |
| Your app targets NativeAOT or aggressive trimming | Avoid dynamic; use reflection only where needed, or source generators |
The default in a modern C# codebase is to stay static, use generics for shape-polymorphic code, and use reflection where you need metadata. dynamic fits a narrow set of scenarios. Outside that set, it tends to cost more than it saves.
10 quizzes