← Open in the full interactive course (progress tracking, search & more)

[Obsolete] never runs a single line of code. It just sits there, as data, until something else — the compiler, or your own reflection code — decides to go looking for it.

You've written [Obsolete("Use CalculateTotal instead")] above a method, and the compiler dutifully warned every caller. You've probably seen [Required] on a model property in an ASP.NET Core project, and somehow a form submission without that field gets rejected before your controller code even runs. Here's the thing that makes attributes genuinely strange the first time you think carefully about them: an attribute is not code that executes. [Obsolete] doesn't "do" anything by itself, in the sense of running instructions. It's a piece of data, permanently attached to a method, a class, a property — baked into the compiled assembly's metadata, sitting there inertly until some other piece of code (the compiler, a validation library, your own code) goes looking for it and decides what to do about it.

That's exactly why this lesson follows reflection directly: reflection is how something goes looking for an attribute. The two are a matched pair — attributes are the declarative metadata; reflection is the mechanism that reads it back.

In this lesson: what attributes actually are, familiar built-in ones you've already used, writing your own custom attribute, restricting where it can apply with AttributeUsage, and a full worked example reading a custom attribute back via reflection to enforce a real rule.

What Is It?

The Simple Explanation

An attribute is a labeled sticky note you attach to a class, method, property, or parameter in your source code, written in square brackets: [Obsolete], [Serializable], [Required]. The note itself does nothing on its own — it just sits there, permanently attached, as part of the compiled type's metadata. What gives it meaning is entirely up to whoever later reads that note and decides to act on it.

The Technical Definition

An attribute is an instance of a class deriving (directly or indirectly) from System.Attribute, applied to a code element — an assembly, type, method, property, field, or parameter — using [AttributeName(...)] syntax. The C# compiler stores the attribute and its constructor arguments as part of the target element's metadata in the compiled assembly. Nothing about applying an attribute causes any code to run at the point of application; consuming its meaning is entirely the responsibility of whatever tool later reads it back — most commonly via reflection, though the C# compiler itself also reads certain well-known attributes directly (like [Obsolete], to emit a warning).

Ordinary method call

Attribute application

Why Does It Exist?

The Problem

Some information about a type or member is genuinely useful to record, but doesn't belong in the type's actual logic. "This method is deprecated." "This property is required for a form to be valid." "This class can be converted to a stream of bytes." "This method is a unit test the runner should discover and execute." None of that is behavior the type performs itself — it's metadata about the type, meant for some external tool to act on. Before attributes, expressing this kind of information meant separate configuration files, naming conventions the tool had to guess at, or — worse — no standard way to express it at all, leaving every framework to invent its own bespoke convention.

The Need

Developers needed a standard, language-level way to attach structured, typed metadata directly to the code element it describes — kept right next to the code (rather than in a separate file that inevitably drifts out of sync), discoverable uniformly by any tool via reflection, and validated by the compiler like any other C# code (real types, real constructors, real compile errors for a typo).

The Solution

Attributes. A single, uniform mechanism — an ordinary class deriving from Attribute, applied with bracket syntax — that the entire .NET ecosystem shares. The compiler itself is just the first, most privileged consumer: it specifically knows how to react to a handful of attributes like [Obsolete]. Everything else — validation, serialization, dependency injection, test discovery — is built by libraries reading attributes back through the exact reflection mechanism from the previous lesson.

Big Picture

METADATA IN, BEHAVIOR OUT — TWO SEPARATE STEPS
[Required] public string Email { get; set; } — you write the attribute (compile time)

Compiler embeds it into the assembly's metadata — nothing has "happened" yet

A validation library reflects over the model, finds [Required], checks Email isn't empty — runtime, and entirely up to that library

The library decides what to do about it — reject the request, add an error message, etc.

How It Works

WRITING, RESTRICTING, AND READING A CUSTOM ATTRIBUTE — STEP BY STEP
1. DEFINE THE ATTRIBUTE — AN ORDINARY CLASS DERIVING FROM Attribute
public class RangeAttribute : Attribute
{
    public int Min { get; }
    public int Max { get; }
    public RangeAttribute(int min, int max) { Min = min; Max = max; }
}
2. RESTRICT WHERE IT CAN BE APPLIED WITH AttributeUsage
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
public class RangeAttribute : Attribute
{
    public int Min { get; }
    public int Max { get; }
    public RangeAttribute(int min, int max) { Min = min; Max = max; }
}
3. APPLY IT — JUST LIKE ANY BUILT-IN ATTRIBUTE
public class Product
{
    [Range(0, 1000)]
    public int Quantity { get; set; }
}
4. READ IT BACK VIA REFLECTION — WHERE IT ACTUALLY BECOMES MEANINGFUL
PropertyInfo prop = typeof(Product).GetProperty("Quantity")!;
RangeAttribute? range = prop.GetCustomAttribute<RangeAttribute>();
if (range is not null)
{
    Console.WriteLine($"Quantity must be between {range.Min} and {range.Max}");
}

Simple Example

Built-in attributes you've very likely already written, now seen for what they actually are:

[Obsolete("Use CalculateTotal(Order) instead — this overload ignores tax.")]
public decimal CalculateTotal(decimal subtotal) => subtotal;
// The COMPILER itself reads this attribute back and emits a warning at every call site —
// no reflection involved for this specific case; the compiler is a privileged, built-in consumer.

[Serializable]
public class LegacySettings { public string Theme = "Dark"; }
// A marker for the (legacy) BinaryFormatter serialization infrastructure to check via reflection
// before attempting to serialize an instance of this type.

Meaning: Two completely different consumers reading the exact same kind of thing — a compile-time consumer (the C# compiler, for [Obsolete]) and a runtime consumer (reflection-based serialization infrastructure, for [Serializable]). The attribute mechanism itself doesn't care which one reads it; that's the point of it being a general-purpose, uniform system.

Real-World Example

A full, working validation attribute — genuinely close to how ASP.NET Core's own data-annotation attributes ([Required], [Range], [StringLength]) work internally: a custom attribute class, applied to model properties, enforced entirely through reflection at runtime.

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class RangeAttribute : Attribute
{
    public int Min { get; }
    public int Max { get; }
    public RangeAttribute(int min, int max) { Min = min; Max = max; }
}

public class Product
{
    public string Name { get; set; } = "";

    [Range(0, 1000)]
    public int Quantity { get; set; }

    [Range(1, 5)]
    public int Rating { get; set; }
}

public static class Validator
{
    public static List<string> Validate(object instance)
    {
        var errors = new List<string>();
        Type type = instance.GetType();

        foreach (PropertyInfo prop in type.GetProperties())
        {
            var range = prop.GetCustomAttribute<RangeAttribute>();
            if (range is null) continue; // this property has no [Range] attribute — nothing to check

            var value = prop.GetValue(instance);
            if (value is int intValue && (intValue < range.Min || intValue > range.Max))
            {
                errors.Add($"{prop.Name} must be between {range.Min} and {range.Max}, but was {intValue}.");
            }
        }
        return errors;
    }
}

var product = new Product { Name = "Widget", Quantity = 1500, Rating = 3 };
var errors = Validator.Validate(product);
// errors: ["Quantity must be between 0 and 1000, but was 1500."]
// Rating (3) is within [1, 5], so it produces no error — and Name has no [Range] attribute at all, so it's skipped entirely

Notice what Validator does not contain: any mention of Product, Quantity, or Rating anywhere in its code. It's entirely generic — it would validate any class with any number of [Range]-attributed int properties, unchanged. This is the exact combination the whole lesson has been building toward: attributes supply the declarative "what the rule is," and reflection supplies the generic "go find and enforce it."

Analogy

A warning label on a product, not an instruction the product follows

An attribute is like a printed warning label on a bottle: "Keep refrigerated." The label itself doesn't refrigerate anything — it's just information, permanently attached to the bottle, sitting there whether or not anyone ever reads it. It only becomes meaningful when someone — a store employee stocking the shelf — reads the label and decides to act on it by putting the bottle in the fridge. A different reader (a customer at home) might read the exact same label and act on it differently (put it in their own fridge). The label doesn't dictate the action; it just carries the fact, and whoever reads it supplies the behavior.

Reflection is the act of reading the label. Without something choosing to look, [Range(0, 1000)] is just as inert as an unread warning sticker.

Under the Hood

HOW THE COMPILER ACTUALLY STORES AN ATTRIBUTE
ATTRIBUTES ARE COMPILED INTO THE ASSEMBLY'S METADATA, NOT ITS EXECUTABLE CODE

Common Confusion

1. "Attributes execute code" — no, they're pure metadata; a consumer executes code

This is the single most important thing to internalize: applying [Range(0, 1000)] has zero runtime effect until something else — a validator, the compiler, a serializer — deliberately goes looking for it. Writing an attribute with no consumer anywhere in the program compiles perfectly fine and does, quite literally, nothing.

2. Constructor arguments and named-property arguments are two different things in attribute syntax

[Range(0, 1000)] uses positional constructor arguments (matching RangeAttribute's constructor exactly, in order). Some attributes also support [SomeAttribute(SomeProperty = value)] syntax — setting a public property on the attribute instance after construction, by name — which is why you'll sometimes see attribute usages mixing both styles, like [Range(0, 1000, ErrorMessage = "Out of range")]. Both are compiled the same way: a constructor call, optionally followed by property assignments.

3. Attribute constructor arguments must be compile-time constants

You can't write [Range(GetMinFromConfig(), 1000)] — attribute constructor arguments must be values the compiler can fully resolve at compile time (literals, const fields, typeof() expressions, and a few similar cases), never the result of calling an arbitrary method. This follows directly from attributes being baked into static metadata rather than executed — there's no "runtime" at the point an attribute is applied to compute a dynamic value from.

Common Mistakes

Mistake 1 — Writing a custom attribute and expecting it to "just work" without a consumer

Applying [Range(0, 1000)] to a property and being surprised when an out-of-range value doesn't get rejected anywhere. An attribute is inert without something reading it — either use a framework that already knows how to consume it (data-annotation attributes with ASP.NET Core model binding, for instance), or write the consuming reflection code yourself, as shown in the Real-World Example.

Mistake 2 — Forgetting [AttributeUsage] and letting the attribute be applied in nonsensical places

A validation attribute meant only for properties, with no [AttributeUsage] restriction, accidentally applied to a class or a method — compiling cleanly, then failing to do anything meaningful (or worse, throwing at runtime when a consumer assumes it's always on a property).

Always specify [AttributeUsage(AttributeTargets.X)] deliberately — it turns a class of misuse into a compile error instead of a silent or runtime surprise.

Mistake 3 — Calling GetCustomAttribute<T>() repeatedly in a hot path without caching

Reflecting for the same attribute on the same member on every request, in a loop that could reasonably cache the result once. This is exactly the reflection performance trap from the previous lesson, now specifically about attribute lookups (which construct a new attribute instance from metadata each time, per "Under the Hood").

Look attributes up once — typically at startup, or the first time a given type is encountered — and cache the result for reuse, exactly as the reflection lesson recommended for member lookups generally.

When Should I Use It?

Rule of thumb: Before writing a custom attribute, ask "who is going to read this back, and how?" If you don't have a clear answer, the attribute will compile fine and accomplish nothing — attributes are only as useful as the consumer that eventually reflects over them.

Mental Model

Attribute = a sticky note attached to code, stored as metadata — inert on its own.
AttributeUsage = the rule for which kinds of notes can go where.
Reflection = the act of reading the note back, at runtime.
The consumer (compiler, validator, serializer, test runner) = the one who decides what the note actually means and does something about it.

Remember: "Does this attribute run code?" — no, never, by itself. "Does something eventually run code because of it?" — only if a consumer chose to look.

Key Takeaway


Check Your Understanding

You've seen what attributes are, how to write and restrict a custom one, and how reflection is what gives an attribute meaning. Let's check your understanding.

1. You define a custom [Range(0, 1000)] attribute and apply it to a property, but write no code anywhere that reads it back. What happens at runtime when that property is set to an out-of-range value?

Show answer

Correct: B

Why B is correct: This is the central point of the lesson — an attribute never enforces anything on its own. Without a consumer (validation code, a framework) reflecting over it and acting on what it finds, [Range(0, 1000)] is exactly as inert as an unread label.

Why A is incorrect: Attributes never execute code automatically, regardless of what they're named or what they seem to imply — "validation attribute" describes intent, not automatic behavior.

Why C is incorrect: The compiler has no built-in understanding of a custom RangeAttribute you defined yourself — it only reacts specially to a small set of attributes it has explicit knowledge of, like [Obsolete].

Why D is incorrect: Nothing about applying an attribute changes how a property's setter behaves — the property still just stores whatever value is assigned to it.

Reinforcement: Always ask "who reads this attribute back?" — the answer determines whether it does anything at all.

2. What does [AttributeUsage(AttributeTargets.Property)] on a custom attribute class actually accomplish?

Show answer

Correct: B

Why B is correct: AttributeUsage declares valid application targets, and the compiler enforces that restriction — applying the attribute somewhere not listed becomes a compile-time error, turning a category of misuse into an immediate, caught mistake.

Why A is incorrect: This is the same misconception as question 1 — AttributeUsage only restricts where the attribute can be applied, it doesn't give the attribute any enforcement behavior of its own.

Why C is incorrect: AttributeUsage has no effect on reflection performance — it's a compile-time placement restriction, unrelated to runtime lookup speed.

Why D is incorrect: AttributeUsage is optional; without it, a custom attribute defaults to being applicable almost everywhere, which is exactly the "Common Mistakes" scenario this lesson warns against, not a compile failure.

Reinforcement: AttributeUsage is a compile-time placement guardrail — it says nothing about runtime behavior, which always depends entirely on the consumer.

3. Why can't you write [Range(GetMinValue(), 1000)], calling a method to compute the first argument?

Show answer

Correct: B

Why B is correct: As covered in "Common Confusion" and "Under the Hood," attribute arguments are embedded directly into compiled metadata — they must be values the compiler can fully determine at compile time, which rules out calling an arbitrary method whose result is only known at runtime.

Why A is incorrect: This restriction applies to every attribute, not something specific to a particular attribute class's design.

Why C is incorrect: The restriction isn't about the return type at all — even a method returning int can't be used, because the restriction is about compile-time-constant-ness, not type.

Why D is incorrect: This genuinely fails to compile — it's a real, well-known C# constraint on attribute arguments.

Reinforcement: Attribute arguments must be compile-time constants because attributes are compiled metadata, not executed code — this is a direct, logical consequence of everything else this lesson establishes.

4. In the worked Validator.Validate(object instance) example, why does the method work correctly for any class, not just Product?

Show answer

Correct: B

Why B is correct: Validate never mentions Product, Quantity, or Rating — it reflects over whatever type it's given, checks each property for the attribute, and only validates the ones that have it. That genericity is exactly the payoff of combining attributes with reflection.

Why A is incorrect: There's no Product-specific code anywhere in Validator — it's entirely generic, which is precisely the point being tested.

Why C is incorrect: Attributes never apply themselves automatically — each property in Product was explicitly marked (or not marked) with [Range] in source code.

Why D is incorrect: There's no inheritance relationship between Validator and Product in this example — Validator works through reflection on an arbitrary object parameter, not through any type relationship.

Reinforcement: This is the practical payoff of the whole lesson — attributes supply declarative "what to check," and reflection supplies the generic "how to find and act on it," together enabling code that works correctly on types it was never written against.

You now understand that attributes are pure metadata — and exactly how reflection is what gives them any real effect at all. Next up: source generators, a compile-time alternative to the reflection-at-runtime pattern you've just built.


dotnetmadeeasy.com — Learn C# and .NET, the right way.