This lesson walks through the useful attributes that ship with .NET out of the box: deprecation markers, debug-only method calls, native interop, caller-info helpers, enum flag bits, debugger UX hints, and the nullable-aware annotations that the compiler reads. None of these need a NuGet package. They all live in the framework's standard libraries.
The framework ships dozens of attribute types, scattered across half a dozen namespaces. Grouping them by function makes them easier to remember.
| Group | Example attributes | Lives in |
|---|---|---|
| Lifecycle / API hygiene | [Obsolete], [Conditional] | System, System.Diagnostics |
| Serialization | [Serializable], [JsonInclude], [JsonIgnore] | System, System.Text.Json.Serialization |
| Native interop | [DllImport], [StructLayout], [FieldOffset] | System.Runtime.InteropServices |
| Caller info / diagnostics | [CallerMemberName], [CallerFilePath], [CallerLineNumber], [CallerArgumentExpression] | System.Runtime.CompilerServices |
| Enum / flags | [Flags] | System |
| Threading | [ThreadStatic] | System |
| Debugger UX | [DebuggerDisplay], [DebuggerBrowsable] | System.Diagnostics |
| Nullable-aware contracts | [MemberNotNull], [NotNull], [MaybeNull] | System.Diagnostics.CodeAnalysis |
| Attribute metadata | [AttributeUsage] | System |
The rest of this lesson works through each group with E-Commerce examples. The focus is on knowing what each one does and when to use it.
The diagram captures a useful split. Some attributes do their job at compile time (the compiler reads them and changes the code it emits). Others do their job at runtime (the framework, the debugger, or a serializer reads them through reflection). A few do both. This distinction matters throughout the lesson.
[Obsolete]: Marking Old APIs[Obsolete] is the standard way to signal that a member should not be used anymore. The compiler reads it and emits a warning, or an error, at every call site.
The code compiles and runs, but the compiler prints a warning at the call site:
Sometimes a warning isn't strong enough. The second constructor argument turns the warning into a hard error, which blocks the build.
[Obsolete] can sit on classes, methods, properties, events, fields, constructors, enum members, almost anywhere. The compiler walks every call site, every type reference, every property access, and emits the warning or error for each one.
There's no runtime cost. [Obsolete] is purely a compile-time signal. The IL emitted for an obsolete method is identical to the IL for a non-obsolete method.
[Conditional]: Calls That Disappear in Release Builds[Conditional("DEBUG")] tells the compiler that calls to the marked method should only be emitted if a given preprocessor symbol is defined. If the symbol isn't defined, the calls vanish from the compiled output entirely. The method body still gets compiled (it lives in the assembly), but every call site that didn't have the symbol defined turns into nothing.
Output (Debug build):
Output (Release build):
In a Release build, the DumpCart call is gone. Not "called and short-circuited," not "called and returned immediately," literally absent from the IL. That's why [Conditional] is the standard way to write assert-style helpers: in production the helper has zero cost, including the cost of evaluating its arguments.
That last detail matters and is the main reason [Conditional] differs from #if DEBUG. Compare them:
Output (Debug build):
Output (Release build):
The compile-time behavior is the same in this snippet, but the maintenance cost is different. With [Conditional], callers write normal-looking code. The compiler removes the call automatically. With #if DEBUG, every call site has to be wrapped in the directive, which is noisier and easier to forget. Debug.Assert, Debug.WriteLine, and Trace.WriteLine all use [Conditional] for exactly this reason.
Two rules: the method must return void, because the compiler can't make up a return value when it deletes the call. And the method's arguments are evaluated only when the call is included; if the call is removed, any side-effects in the arguments are removed too. That's normally a feature (no expensive string formatting in Release), but it can be a problem if the argument is expected to do work.
Zero cost in Release builds when the symbol isn't defined. The call site is erased and the arguments are never evaluated. Debug builds pay the normal call cost.
[Serializable] and Modern Serialization Attributes[Serializable] is the original .NET signal that a type is safe to serialize using the old BinaryFormatter / SoapFormatter mechanisms. It applies to classes, structs, and delegates, and it has zero effect unless someone runs the type through a formatter that reads it.
In modern .NET (5+), BinaryFormatter is deprecated and disabled by default because of well-documented security holes. The [Serializable] attribute itself isn't gone, but most new code should use one of the modern serializers instead. System.Text.Json is the default for JSON; System.Xml.Serialization is still the standard for XML; MessagePack and protobuf-net are the common binary choices.
For System.Text.Json, the common attributes are [JsonInclude], [JsonIgnore], and [JsonPropertyName].
[JsonIgnore] keeps InternalNotes out of the output entirely, the right behavior for fields that hold internal state. [JsonPropertyName] overrides the name in the JSON, useful when the C# side prefers PascalCase but the wire format is snake_case. [JsonInclude] is the opposite: it forces the serializer to include a non-public member or a field that it would otherwise skip.
These are normal attributes that follow the same model as everything else. The serializer reads them at runtime through reflection and adjusts its behavior. No special method call is needed; applying the attribute is the entire interface.
[DllImport]: Calling Native Code (P/Invoke)To call a function in a native library (a Win32 API, a Linux .so, a vendor's C SDK), use [DllImport]. The attribute tells the runtime that the marked method's body lives in an external native library, and which library and entry point to use.
Output (on Windows, varies):
The method has no body in C#. The extern keyword signals that, and [DllImport] tells the runtime where to find the real implementation. At call time, the runtime loads kernel32.dll, looks up GetTickCount, marshals any arguments from managed types to native types, calls the function, and marshals the return value back.
P/Invoke is a deep topic on its own (calling conventions, character set handling, structure marshalling, error handling via SetLastError). It shows up here because [DllImport] is one of the canonical examples of an attribute that changes how the runtime treats a member. Without the attribute, an extern method has no implementation and the runtime can't call it; with the attribute, the method becomes a thin shim that bridges managed and native code.
A newer alternative: [LibraryImport] (added in .NET 7) is a source-generator-based replacement for [DllImport] that produces the marshalling code at compile time instead of at runtime. It's faster and AOT-friendly, but [DllImport] is still common in existing code.
The caller-info attributes let a method discover, at compile time, the caller's context. The compiler fills in the values from the call site. No reflection runs at runtime.
| Attribute | What the compiler fills in |
|---|---|
[CallerMemberName] | Name of the member that contained the call (method, property, event) |
[CallerFilePath] | Full path of the source file that contained the call |
[CallerLineNumber] | Line number of the call |
[CallerArgumentExpression("paramName")] | Source text of the expression passed for the named parameter (C# 10+) |
The classic use is logging that knows where it was called from, without each call site having to spell it out.
The caller passed only the message. The compiler filled in the rest from the call site's metadata. That's why every parameter has a default value: at the declaration the compiler treats them as ordinary optional parameters, but at the call site it overrides the defaults with the captured information.
A practical use is the INotifyPropertyChanged pattern, where a model raises a PropertyChanged event whenever a property changes. Spelling out the property name in every setter is error-prone (one typo and the UI stops updating). [CallerMemberName] removes the duplication entirely.
The Notify method has no idea which property called it. The compiler does. Every call from inside a property setter passes the setter's property name automatically.
C# 10 added [CallerArgumentExpression], which captures the source text of an argument expression. The standard library uses it to power the new argument-checking helpers like ArgumentNullException.ThrowIfNull.
The caller passed couponCode. The compiler captured "couponCode" as the source text and slotted it in as paramName. The exception now points to exactly the variable the caller used, with no extra effort. This is how ArgumentNullException.ThrowIfNull(couponCode) produces a useful error message without the caller writing nameof(couponCode).
Caller-info attributes are zero-cost at runtime. The values are baked into the call site as constants at compile time, the same cost as passing a string literal.
[AttributeUsage]: Attributes On Attributes[AttributeUsage] is an attribute you apply to an attribute class. It controls two things: which kinds of targets the attribute can decorate (classes, methods, properties, parameters, etc.), and whether the same attribute can be applied more than once to the same target.
Without [AttributeUsage], the compiler lets the attribute decorate anything, which is rarely the intent. With it, the compiler enforces the targets. Applying the attribute somewhere illegal (say, on a method when the attribute is restricted to properties) produces a compile error:
The properties on AttributeUsage:
| Property | Default | What it controls |
|---|---|---|
ValidOn (constructor arg) | AttributeTargets.All | Which member kinds accept the attribute |
AllowMultiple | false | Whether the same attribute can be applied multiple times to one target |
Inherited | true | Whether derived classes inherit the attribute when reflection asks for it |
Apply [AttributeUsage] when defining custom attribute types. It's mentioned here so the syntax isn't unfamiliar later.
[Flags]: Bitwise Enums[Flags] tells C# that an enum's values are meant to be combined with bitwise operators. The attribute doesn't change how the enum works in code (| and & already work on any enum), but it changes how ToString() formats values and signals intent to anyone reading the code.
Without [Flags], s.ToString() would print the underlying number (7) because the runtime wouldn't know to break it down. With it, ToString walks the enum's defined values and prints the comma-separated names of the bits that are set.
Two rules make [Flags] work cleanly. Each named value must be a distinct power of two (1, 2, 4, 8, 16, ...) or a deliberate combination of them. A None = 0 entry is the conventional way to express "no flags set," because zero is the natural default for any numeric type.
[Flags] itself is metadata only. HasFlag does a single bitwise AND, which is fast, but it boxes the enum to Enum before C# 7.3 in some scenarios. On modern .NET it's effectively free.
[ThreadStatic]: Per-Thread StorageA normal static field is shared by every thread in the process. [ThreadStatic] changes that. Each thread gets its own independent copy of the field, initialized to the default value (null for reference types, 0 for numerics, etc.) the first time the thread accesses it.
Output (order varies):
Each thread sees only its own customer. If _currentCustomer were a normal static field, both threads would race on the same memory, and the last one to write would clobber the first.
[ThreadStatic] has a sharp edge. A field initializer like [ThreadStatic] static int _count = 5; runs on whichever thread accesses the field first; every other thread gets the default 0, not 5. For a non-default starting value per thread, write a property that lazy-initializes inside the getter, or use ThreadLocal<T> from System.Threading, the modern replacement that handles initialization correctly.
In modern code, use AsyncLocal<T> instead of [ThreadStatic] when the context should follow asynchronous operations across thread hops. [ThreadStatic] is "this thread only" and won't flow through await. The choice depends on whether per-thread or per-logical-flow scoping is needed.
Slightly more expensive than a regular static field. The runtime maintains per-thread slots, and accessing the field includes a small thread-local indirection. Fine for occasional use; not something to call in a tight loop.
[DebuggerDisplay] and [DebuggerBrowsable]These two attributes don't change runtime behavior. They change what the debugger shows on hover or when expanding an object in the watch window. For complex types, a one-line summary that surfaces the important fields makes debugging faster.
[DebuggerDisplay] takes a string with {Expression} placeholders. The debugger evaluates the expressions in the context of the object and shows the result instead of the default Namespace.TypeName rendering.
The ,nq modifier strips the quotes around string expressions, which keeps the display clean. Without it, the debugger would show "Wireless Mouse" ("WM-001") - "$29.99", which is uglier.
[DebuggerBrowsable] controls how a member appears when an object is expanded in the debugger.
The three states are Collapsed (default), Never (hide the member), and RootHidden (don't show the member itself, but expand its children inline as if they were directly on the parent). RootHidden is what makes wrapper types like Lazy<T> show the underlying value directly instead of nesting it inside a Value node.
Neither attribute affects production. They're pure debugger UX. Use them on types that are frequently inspected.
[StructLayout] and [FieldOffset]When passing a struct to native code, the runtime has to lay out its fields in memory in a way the native side expects. [StructLayout] controls that layout.
LayoutKind.Sequential says "lay fields out in the order they're declared, with default packing." LayoutKind.Explicit allows each field's offset to be set by hand using [FieldOffset(n)], which is how C# expresses a C union:
Output (illustrative):
The Price field and the Low64/High64 fields all share the same 16 bytes of storage. Writing through Price is visible through Low64/High64 and vice versa, because they're the same bytes.
These two attributes show up almost exclusively in interop and very low-level code. They're not needed for normal application development. Seeing them on a struct signals that the type is bridging managed and native memory and has rules the compiler can't enforce on its own.
Layout attributes have no runtime cost beyond the storage they describe. They affect how the runtime arranges memory, not how it accesses it.
C# 8 enabled nullable reference types, which lets the compiler flow-analyze where null can appear and warn on unsafe dereferences. The flow analysis is good but imperfect, and the nullable-aware attributes provide a way to tell the compiler things it can't figure out on its own. They live in System.Diagnostics.CodeAnalysis.
| Attribute | Meaning |
|---|---|
[NotNull] (on parameter) | After this method returns normally, the parameter is non-null. |
[MaybeNull] (on return) | The return value can be null even if the type is non-nullable. |
[MemberNotNull("X")] | After this method returns normally, the named member is non-null. |
[NotNullWhen(true)] | If the method returns true, the parameter is non-null. |
[NotNullIfNotNull("input")] | The return is null only if the named parameter is null. |
A common case is a property that's initialized through a separate method rather than in the constructor. Without help, the compiler warns that the property might be null. [MemberNotNull] tells it which method does the initialization.
Without [MemberNotNull(nameof(Owner))], the compiler would warn that Owner is uninitialized after the constructor, because it can't see through the Initialize call. The attribute teaches it that any call to Initialize guarantees Owner is non-null afterward.
[NotNullWhen(true)] appears on TryParse-style methods. It tells the compiler "if I returned true, the out parameter is non-null":
Without the attribute, dereferencing code.Length inside the if block would draw a nullable warning, even though the code is logically safe. The attribute lets the compiler trust the method's contract.
These attributes don't change runtime behavior. They're hints to the nullable analyzer. The IL is identical whether or not they're applied; they turn warnings on or off.
A small Product type can carry several of these attributes at once, each doing a different job. This combination is common for production domain types.
The [DebuggerDisplay] shapes the debugger view. The [Obsolete] warns anyone still using the old two-argument constructor. The [JsonIgnore] keeps internal notes out of any JSON the type ends up in. The [MemberNotNull] tells the compiler that the main constructor initializes Name and Sku, so nullable analysis stays clean.
None of these attributes know about each other. Each is read by a different consumer (the compiler, the debugger, the serializer, the nullable analyzer), and each does its own job independently. This is the design of the attribute system: pluggable metadata that lets independent tools cooperate around the same type.
10 quizzes