Attributes and Metadata in C#

Vaibhav • September 11, 2025

As your C# programs evolve, you’ll start encountering scenarios where you need to attach extra information to your code - not for the program itself, but for tools, frameworks, or the compiler to interpret. For example, you might want to mark a method as obsolete, specify how a class should be serialized, or control how unit tests are discovered. This is where attributes and metadata come into play.

Attributes are a powerful feature in C# that let you annotate your code with declarative information. This metadata doesn’t change how your code runs directly, but it can influence how other parts of the system behave - including compilers, debuggers, serializers, and custom frameworks. In this article, we’ll explore what attributes are, how to use them, how to create your own, and how they fit into the broader ecosystem of modern C# development.

What Are Attributes?

An attribute is a class that inherits from System.Attribute and can be applied to various program elements - such as classes, methods, properties, parameters, and assemblies. You use attributes to attach metadata to these elements, which can then be read at runtime or compile time using reflection or tooling.

For example, the [Obsolete] attribute marks a method as outdated:

[Obsolete]
public void OldMethod()
{
    Console.WriteLine("This method is obsolete.");
}

When you call OldMethod(), the compiler will show a warning - encouraging you to use a newer alternative. This is metadata in action: the attribute doesn’t change the method’s behavior, but it changes how the compiler treats it.

Attributes are enclosed in square brackets and placed directly above the element they annotate. You can apply multiple attributes by separating them with commas or stacking them vertically.

Common Built-in Attributes

C# provides many built-in attributes that serve different purposes. Here are a few you’ll encounter frequently:

// Marks a method as obsolete
[Obsolete("Use NewMethod instead")]
public void OldMethod() { }

// Marks a method as a test case (used in testing frameworks)
[TestMethod]
public void ShouldAddNumbersCorrectly() { }

// Controls serialization behavior
[Serializable]
public class Person { }

// Specifies a display name for UI or documentation
[DisplayName("Full Name")]
public string Name { get; set; }

Each of these attributes adds metadata that tools or frameworks can interpret. For example, [Serializable] tells the runtime that the class can be converted to a binary or XML format, while [TestMethod] helps test runners discover and execute unit tests.

Using Attributes with Parameters

Many attributes accept parameters to customize their behavior. These parameters are passed like constructor arguments:

[Obsolete("This method is deprecated", true)]
public void DeprecatedMethod() { }

In this example, the second parameter (true) tells the compiler to treat usage of this method as an error, not just a warning. You can also use named parameters:

[Display(Name = "User Name", Description = "The full name of the user")]
public string Name { get; set; }

This is common in UI frameworks and data annotations, where attributes help control how data is displayed or validated.

Applying Attributes to Different Targets

Attributes can be applied to many kinds of program elements:

// Class
[Serializable]
public class Product { }

// Method
[Obsolete]
public void Calculate() { }

// Property
[Display(Name = "Price")]
public decimal Price { get; set; }

// Parameter
public void Save([NotNull] string name) { }

// Assembly
[assembly: CLSCompliant(true)]

Each attribute has a defined set of valid targets. If you apply an attribute to an unsupported target, the compiler will show an error.

Reading Attributes with Reflection

Attributes are most useful when you can read them at runtime. This is done using reflection - a feature that lets you inspect types, methods, and metadata dynamically.

Type type = typeof(Product);
object[] attributes = type.GetCustomAttributes(false);

foreach (object attr in attributes)
{
    Console.WriteLine(attr.GetType().Name);
}

This code retrieves all attributes applied to the Product class and prints their names. You can also filter by specific attribute types:

if (type.IsDefined(typeof(SerializableAttribute), false))
{
    Console.WriteLine("Product is serializable.");
}

This is how frameworks like ASP.NET, Entity Framework, and test runners discover and use metadata to control behavior.

Creating Custom Attributes

You can define your own attributes by creating a class that inherits from System.Attribute. For example:

[AttributeUsage(AttributeTargets.Method)]
public class AuditAttribute : Attribute
{
    public string Action { get; }

    public AuditAttribute(string action)
    {
        Action = action;
    }
}

This attribute can be used to mark methods that should be audited:

[Audit("Delete")]
public void DeleteUser(int id) { }

You can then use reflection to find all methods marked with [Audit] and log their usage. The AttributeUsage attribute controls where your custom attribute can be applied - in this case, only to methods.

Note: Custom attributes should be lightweight and declarative. Avoid putting complex logic inside them - they’re meant for metadata, not behavior.

Controlling Attribute Behavior with AttributeUsage

The AttributeUsage attribute lets you control how your custom attribute behaves. You can specify:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
public class TagAttribute : Attribute
{
    public string Label { get; }

    public TagAttribute(string label)
    {
        Label = label;
    }
}

This attribute can be applied to classes and methods, multiple times, and is not inherited by derived types. These options help you design attributes that behave predictably and safely.

Attributes in Real-World Frameworks

Attributes are everywhere in modern C#. Here are a few examples:

// ASP.NET MVC
[HttpGet]
public IActionResult Index() { }

// Entity Framework
[Key]
public int Id { get; set; }

// NUnit
[Test]
public void ShouldCalculateCorrectly() { }

// Data annotations
[Required]
[EmailAddress]
public string Email { get; set; }

These attributes help frameworks discover routes, validate data, map database fields, and run tests. They’re declarative, readable, and powerful - making your code easier to understand and maintain.

Best Practices for Using Attributes

Attributes are a design tool - use them thoughtfully. Here are some guidelines:

Prefer attributes for declarative metadata - not for logic. Keep them simple and focused. Avoid overusing attributes - too many can clutter your code and make it harder to read. Document custom attributes clearly - explain what they do and how they’re used. Use reflection responsibly - it’s powerful but can be slow and error-prone if misused.

Use attributes to express intent and configuration. Let frameworks and tools interpret them - don’t rely on them for core logic.

Common Mistakes and How to Avoid Them

Here are a few common mistakes developers make with attributes:

Applying attributes to the wrong target - always check AttributeUsage. Forgetting to override Equals() and GetHashCode() in custom attribute classes - which can cause issues in comparisons. Using attributes for behavior - they’re meant for metadata, not execution. Ignoring performance impact of reflection - cache results if you use reflection frequently.

You can apply attributes to assemblies, modules, and return values - not just classes and methods. For example: [assembly: AssemblyVersion("1.0.0.0")].

Summary

Attributes and metadata let you annotate your C# code with declarative information that tools, frameworks, and the compiler can interpret. You’ve learned how to use built-in attributes like [Obsolete] and [Serializable], how to define custom attributes, how to control their behavior with AttributeUsage, and how to read them using reflection.

By using attributes thoughtfully, you make your code more expressive, configurable, and compatible with modern frameworks. In the next article, we’ll explore Preprocessor Directives - a feature that lets you control compilation behavior using special instructions like #if, #define, and #region.