Last Updated: May 22, 2026
An attribute in C# is a chunk of declarative metadata you attach to code so that something else, the compiler, the runtime, a serializer, a test runner, an ORM, can read it later and change its behavior accordingly. This lesson covers what attributes are, the exact syntax for applying them (positional arguments, named arguments, targets, stacking), why they're inert metadata until something explicitly reads them, and a short look at how that reading happens.
An attribute is a label. You write it in square brackets directly above a class, method, property, parameter, or other code element, and the C# compiler embeds that label into the compiled assembly as metadata. The label sits there permanently inside the .dll file, doing nothing on its own. The interesting behavior happens when some other piece of code, usually using reflection, asks "what attributes are on this thing?" and reacts to the answer.
The mental model that matters: an attribute is a sticky note. The compiler glues the note onto the code element. The note has no opinion about the code it's attached to. Anyone reading the assembly can find the note and decide what to do with it. The same note can mean "skip me during serialization" to one library, "render me as a column header" to another, and nothing at all to a third.
The flow is one-way at compile time. The compiler reads the attribute usage, validates it (the attribute type must exist, the arguments must match a constructor), and writes it into the assembly's metadata tables. From that point on, the attribute is just data. A program that never asks about attributes will run identically whether the attributes are there or not.
Under the hood, every attribute you apply is actually a class that derives from System.Attribute. When you write [Required], the compiler is looking for a type named RequiredAttribute (or Required, but the Attribute suffix is the convention) that inherits from System.Attribute. The arguments inside the brackets are constructor arguments for that class. Storing the attribute in metadata is essentially storing the recipe for "construct this attribute instance with these arguments," and reading the attribute later constructs the instance on demand.
The program prints a single line and exits. The [Required] attribute did nothing at runtime, because nothing read it. The metadata is sitting inside the compiled assembly, waiting for a reader. That's the entire point: attributes are passive.
The syntax for applying an attribute is [AttributeName] placed immediately before the code element it decorates. No blank line between the attribute and the element. The compiler accepts the attribute name with or without the Attribute suffix, so [Required] and [RequiredAttribute] refer to the same class. The short form is almost always preferred.
[Table("products")] is shorthand for "construct a TableAttribute instance, passing the string "products" to its constructor, and attach the result as metadata to the Product class." Note the absence of new. You don't write [new Table("products")]; the brackets implicitly mean "construct an instance of this attribute."
A few mechanical rules about applying an attribute:
System.Attribute, directly or indirectly.typeof(SomeType) expressions, or enum members. You can't pass a variable, a method call, or a new expression as an attribute argument.[Table("products")] attached to Product is stored on the Product type, not somewhere global.The compile-time constant rule is a common surprise. Attributes are baked into the assembly at compile time, so their arguments must be values the compiler can serialize at that moment.
The commented-out form fails with CS0182. The fix is to use the literal string directly. If you genuinely need a value computed at runtime, attributes are the wrong tool; you want a regular constructor parameter or a configuration object instead.
Attributes accept two kinds of arguments: positional and named. Positional arguments correspond to the parameters of one of the attribute's constructors and must appear in order. Named arguments set public properties or fields of the attribute by name, after the positional arguments, using PropertyName = value syntax.
The distinction is mechanical, not philosophical. A positional argument means "construct the attribute using this constructor overload with these parameters." A named argument means "after construction, set this property to this value." Both end up stored in metadata, but they take different code paths inside the attribute class.
[Column("product_id", Order = 1, IsNullable = false)] passes "product_id" to the constructor's name parameter (positional), then sets the Order property to 1 and the IsNullable property to false (named). [Column("unit_price")] only uses the positional argument; Order defaults to 0 and IsNullable defaults to true because the constructor sets it.
Named arguments are useful when an attribute has many optional knobs and you don't want to invent a constructor overload for every combination. Without named arguments, an attribute class with five optional fields would need a constructor overload for each subset, which scales badly. With named arguments, one constructor for the truly required values is enough, and everything else is opt-in.
ValidateAttribute has no positional arguments (its parameterless default constructor is used implicitly), so every value is set by name. This is a common shape for "all knobs are optional" attributes.
The order rule: all positional arguments come first, then all named arguments. Mixing them, or putting a named argument before a positional one, is a compile error.
An attribute target is the kind of code element an attribute is allowed to decorate. The full list, expressed via the AttributeTargets enum, covers every element the C# compiler knows how to attach metadata to:
| Target | Applies To |
|---|---|
Assembly | The whole compiled assembly |
Module | A single module within an assembly (rare in practice) |
Class | A class declaration |
Struct | A struct declaration |
Interface | An interface declaration |
Enum | An enum declaration |
Constructor | A constructor |
Method | A method |
Property | A property |
Field | A field |
Event | An event |
Parameter | A parameter (including return value when targeted explicitly) |
ReturnValue | The return value of a method |
GenericParameter | A generic type parameter, e.g. T in class Cart<T> |
All | Any of the above |
Most attributes only make sense in one or two places. A [Column] attribute belongs on a property or a field; a [Table] attribute belongs on a class; an [HttpGet] attribute belongs on a method. The attribute author declares which targets are allowed by putting [AttributeUsage(AttributeTargets.X)] on the attribute class itself. If you try to use an attribute somewhere it doesn't allow, the compiler refuses.
The compiler enforces the target list at every use site. If you write [Cacheable(60)] above a property, you get error CS0592. This is the attribute author's way of saying "I only know how to be a method-level marker; please don't paste me on a property and expect anything sensible."
Sometimes the target is ambiguous from the position alone, and you have to say it explicitly. The most common case is the return value of a method. A naked attribute above a method decorates the method itself. To target the return value instead, you write [return: SomeAttribute]. The general syntax is [target: AttributeName(...)].
The same pattern applies to other "ambiguous" positions:
[assembly: ...] and [module: ...] decorate the whole assembly or module. These usually live in a separate file (often AssemblyInfo.cs) because they don't sit above any specific type. The AssemblyVersion and AssemblyTitle attributes the build system generates use this form.[field: ...] on an auto-property attaches the attribute to the compiler-generated backing field instead of the property. Useful when a serializer specifically inspects fields.[param: ...] on a parameter is rarely needed because the default position already targets the parameter, but it's available for symmetry.The [assembly: ...] line attaches a description attribute to the whole assembly. The [field: NonSerialized] on TempNotes attaches NonSerialized to the hidden backing field the compiler generated for the auto-property, not to the property itself. This is the kind of subtlety that matters when a serializer is hunting for [NonSerialized] markers and only looks at fields.
A single code element can carry many attributes at once. The two valid syntaxes are stacking each attribute on its own pair of brackets, or comma-separating them inside one pair of brackets. Both produce identical metadata.
Most C# codebases use the vertical-stacking form because it diffs more cleanly in source control and reads more naturally when each attribute has multiple arguments. The comma form is fine for short markers.
There's a wrinkle to call out: applying the same attribute type twice requires `AllowMultiple = true` on the attribute's [AttributeUsage] declaration. By default, an attribute can appear at most once per element, so a second instance of the same attribute type on the same element fails to compile.
The fix, if multiple instances of the same attribute genuinely make sense, is to set AllowMultiple = true on the attribute's [AttributeUsage] declaration. The chapter on defining custom attributes covers when to apply it.
Different attribute types stacked on the same element never collide; the single-instance rule only applies per attribute type. So [Required] [MinLength(1)] [MaxLength(100)] on one property is fine because all three are different types.
The order in which stacked attributes appear in source has no semantic meaning. When a reader later asks "what attributes are on Name?", it gets back the set of attributes, and it's the reader's job to make sense of the combination. The reader might pick one, combine all of them, or ignore some, but the order they were written in source is not part of the contract.
This is the part of attributes that most consistently surprises people coming from frameworks where decorators or annotations seem to "do something" the moment you write them. In C#, that's not what's happening. An attribute applied to code does nothing at runtime, by itself, ever. The compiler writes the attribute into the assembly's metadata and walks away. The decorated class, method, or property runs exactly as it would have without the attribute. No interception, no wrapping, no callbacks.
[LogCall] decorates CalculateTotal. Nothing logs. Nothing intercepts the call. The method runs and returns the sum. The [LogCall] attribute is sitting in the assembly's metadata, where any future code can find it, but no logging will ever happen unless some other piece of code explicitly checks for [LogCall] and reacts.
This is the right place to break a common assumption. Frameworks that seem to "make attributes do things" (ASP.NET routing, EF Core column mapping, xUnit test discovery) are all reading those attributes through reflection at startup or at test-discovery time, then dispatching based on what they find. The attribute itself is still inert. The framework is the thing that gives the attribute meaning.
The diagram shows the two possible paths from metadata. Without a reader, the attribute does nothing forever; the method executes plain. With a reader, the attribute's presence triggers some behavior at the reader's discretion. The compiler does not pick which path; whoever reads the assembly does.
There's exactly one category of exception: a handful of attributes are recognized directly by the compiler or the runtime, not by reflection. [Obsolete] produces a compiler warning at the call site. [Conditional] causes the compiler to omit calls to the decorated method in builds that don't define the matching symbol. [DllImport] instructs the runtime to bind a method to a native function. Those attributes are special-cased by the platform itself. They're the exception, not the rule.
Attributes only earn their keep when something reads them. The reading API lives in the System.Reflection namespace, and the simplest entry point is GetCustomAttributes on a Type or MemberInfo. The goal here is just to see one tiny example so the purpose is concrete.
typeof(Product) returns a Type object that represents the Product class. GetCustomAttributes(typeof(TableAttribute), inherit: false) asks for every TableAttribute directly applied to that type, returns them as an array of object, and the cast inside the foreach reads the Name property. The output proves what was stored in metadata: the attribute argument "products" survived compilation and came back out the other side intact.
GetCustomAttributes walks the assembly's metadata tables and constructs fresh attribute instances on every call. The cost is small in absolute terms but real, and it scales with the number of scanned types or members. Real frameworks (EF Core, ASP.NET, xUnit) do this scan once at startup or at test-discovery time and cache the results, not on every request or test run.
For this lesson, the takeaway is just "attributes are passive metadata, and reflection is the bridge that makes them active."
The .NET ecosystem leans heavily on attributes. Knowing the shape of the most common usage patterns helps you read other people's code, even before you ever write your own attribute class. Each category here gets a deeper treatment elsewhere in the curriculum or in the framework's own documentation; the goal here is recognition, not coverage.
Serialization. Libraries that convert objects to JSON, XML, or binary formats use attributes to control naming, ordering, and inclusion. System.Text.Json uses [JsonPropertyName("...")] to override property names, [JsonIgnore] to skip properties, and [JsonPropertyOrder(n)] to control field order. Newtonsoft.Json uses a parallel set ([JsonProperty], [JsonIgnore]). The decorated class describes its serialization contract through attributes; the serializer reads them at runtime to do the right thing.
The output names match what the attributes asked for. InternalCost is absent because [JsonIgnore] told the serializer to skip it. None of this is hard-coded in the serializer; the serializer reads the attributes on the type and reacts.
Validation. ASP.NET Core, FluentValidation, and similar libraries inspect attributes like [Required], [StringLength(100)], [Range(0, 1000)], and [EmailAddress] to validate incoming models. The model class declares its rules with attributes; the framework runs the rules before the controller action sees the data.
ORM mapping. Entity Framework Core, Dapper.Contrib, and other data-access libraries use attributes to map class properties to database columns. [Table("products")], [Key], [Column("unit_price")], [ForeignKey("CustomerId")] describe the schema in the type itself, and the ORM uses them to generate SQL.
Test frameworks. xUnit, NUnit, and MSTest mark test methods with attributes ([Fact], [Theory], [InlineData(...)], [Test]) so the test runner can discover them. The test runner scans the assembly, finds the methods bearing those attributes, and executes them.
Compiler and analyzer hints. Some attributes feed into compiler diagnostics: [Obsolete("Use NewMethod instead")] produces a warning at every call site, [CallerMemberName] injects the caller's name as a default argument, and [NotNull] / [MaybeNull] from System.Diagnostics.CodeAnalysis refine nullable-reference-type flow analysis. The _Built-in Attributes_ lesson walks through these in depth.
Interop. [DllImport("library.dll")] binds a managed method signature to a native function in a system library. [StructLayout(LayoutKind.Sequential)] controls how a struct is laid out in memory for talking to native code. These attributes don't just sit there; the runtime itself interprets them when the type is loaded.
The pattern across all of these is the same: a class or method declares its intent with attributes, and a separate piece of code (a library, the runtime, the compiler) reads those attributes and acts on them. Attributes are the lingua franca of "describe yourself declaratively so something else can use you generically."