Attributes in C#

By the Syncfusion DevTutorials team · Last updated

Label code with attributes: built-in attributes, Obsolete, Conditional, caller information, parameters, custom attributes, targets, reading them with reflection and validation.

Introduction to Attributes

Think about the labels on a parcel.

The parcel holds the item. The labels say how to handle it: "Fragile", "This side up", "Keep dry". The labels do not change what is inside. They give instructions to whoever handles the parcel.

Attributes are labels for your code. You put them in square brackets above a class, method or property:

[Obsolete("Use SendAsync instead")]
public void Send() { ... }

The attribute does not change what Send does. It tells someone something about it. Here, it tells the compiler to warn anyone who still calls Send.

In simple words: an attribute is a sticky note on your code. Different tools read these notes and do different things based on what they say.

Who reads these labels?

  • The compiler reads labels like [Obsolete] and [Conditional]. It warns you or changes the build based on the label.
  • Frameworks and libraries read labels like [Required] or [JsonPropertyName]. They use the label to handle your data correctly.
  • Your code can read labels too. You learned how to do this with reflection in the previous lesson.

Easy Way to Remember: an attribute is a sticky note on your code. It changes nothing by itself. It matters only when someone reads it.

What You Will Learn

In this lesson, you will learn:

  1. How to apply attributes, and where they can go.
  2. The most useful built-in attributes.
  3. [Obsolete], [Conditional] and caller information attributes.
  4. How attribute parameters work.
  5. How to create your own custom attributes.
  6. How to control where attributes can be placed with [AttributeUsage].
  7. How to read attributes using reflection.
  8. How to build simple validation using attributes.

All runnable examples on this page run in your browser. A few examples cause a compile error on purpose, to show what an attribute does. Those are shown as plain code, because a program that does not compile cannot run.

Start With a Working Example

Press Run.

Labels that code can read

using System;
using System.Reflection;

foreach (var property in typeof(SignUpForm).GetProperties())
{
    var label = property.GetCustomAttribute<DisplayLabelAttribute>();
    string text = label?.Text ?? property.Name;
    Console.WriteLine($"{text}: {property.GetValue(new SignUpForm())}");
}

class SignUpForm
{
    [DisplayLabel("Full name")]
    public string Name { get; set; } = "Asha Rao";

    [DisplayLabel("Email address")]
    public string Email { get; set; } = "asha@example.com";

    public int Age { get; set; } = 29;
}

class DisplayLabelAttribute : Attribute
{
    public string Text { get; }
    public DisplayLabelAttribute(string text) => Text = text;
}

The class stores the label. The loop reads the label and prints it. Age has no label, so the loop just prints the property name instead.

This is the main idea: write a label once, then read it and use it in many places.

Try This

Add [DisplayLabel("Your age")] above Age and run again. The output changes, and the loop did not change at all.

Applying Attributes

You apply an attribute by writing its name in square brackets, directly before the item it describes.

[Serializable]
public class Invoice { }

Three rules about the syntax:

  • The Attribute suffix is optional. The class is called ObsoleteAttribute, but you can write [Obsolete].
  • You can stack attributes. Write them on separate lines, or put several in one bracket separated by commas: [Serializable, Obsolete].
  • Brackets can take arguments, like a method call: [Obsolete("Use V2")].

Stacking attributes

using System;
using System.Diagnostics;

var status = OrderStatus.Paid | OrderStatus.Shipped;
Console.WriteLine(status);
Console.WriteLine(status.HasFlag(OrderStatus.Shipped));

var point = new GpsPoint { Lat = 13.08, Lng = 80.27 };
Console.WriteLine($"{point.Lat}, {point.Lng}");

[Flags]
enum OrderStatus { None = 0, Paid = 1, Packed = 2, Shipped = 4 }

[Serializable, DebuggerDisplay("({Lat}, {Lng})")]
class GpsPoint
{
    public double Lat { get; set; }
    public double Lng { get; set; }
}

[Flags] tells .NET: "These enum values can be combined". That is why ToString() prints Paid, Shipped instead of the number 5. Try combining them without [Flags] and you get just a number.

[DebuggerDisplay] tells the Visual Studio debugger: "Show this text when the user looks at this object in the debugger". It does not affect what your program does when it runs. Not every attribute matters to the running program.

Built-in Attributes

.NET includes hundreds of attributes. You will see these ones most often.

AttributeWho reads itWhat it does
[Obsolete]CompilerWarns, or errors, when old code is used
[Conditional("X")]CompilerRemoves calls unless symbol X is defined
[CallerMemberName] and friendsCompilerFills in the caller's name, file or line number
[Flags]RuntimeLets enum values combine and print clearly
[Serializable]Older serializersMarks a type as safe to serialize
[DebuggerDisplay]DebuggerChanges what the debugger shows
[Required], [Range], [EmailAddress]Validation codeRules for valid data
[JsonPropertyName], [JsonIgnore]System.Text.JsonControl how JSON is written

The next three sections explain the compiler attributes in detail.

Obsolete Attribute

Code changes over time. Sometimes a method is replaced by a better one, but you cannot delete the old method yet, because other code still calls it.

[Obsolete] marks the old member. Anyone who uses it gets a compiler warning with your message.

Mark an old method as obsolete

using System;

var sender = new SmsSender();
sender.Send("Your OTP is 4821");          // the editor underlines this call
sender.SendMessage("Your OTP is 4821", "+91 98400 12345");

class SmsSender
{
    [Obsolete("Use SendMessage(text, phone) instead. Send will be removed in v3.")]
    public void Send(string text) => Console.WriteLine($"(old) {text}");

    public void SendMessage(string text, string phone) =>
        Console.WriteLine($"To {phone}: {text}");
}

The program still runs, because a warning does not stop the build. Hover over the underlined Send in the editor to read the warning, CS0618.

Turning the Warning Into an Error

Pass true as a second argument, and using the member becomes a compile error (CS0619). The program cannot be built until every caller is updated.

[Obsolete("Use SendMessage(text, phone) instead.", true)]
public void Send(string text) { ... }

sender.Send("Hi");   // error CS0619: 'SmsSender.Send(string)' is obsolete:
                     // 'Use SendMessage(text, phone) instead.'

A common migration plan is: warning in version 2, error in version 3, removed in version 4. This gives callers time to update.

Always say what to use instead in the message. "This is obsolete" leaves the reader stuck. "Use SendMessage(text, phone) instead" tells them exactly what to do.

Conditional Attribute

Some methods are only useful while developing, such as detailed logging or debug checks. You want the calls in your code, but you do not want them to run in the final release build.

[Conditional("SYMBOL")] does this. If SYMBOL is not defined, the compiler erases every call to the method. It even skips preparing the arguments.

A symbol is just a name. You turn it on with #define at the top of a file, or in the project settings. Visual Studio automatically turns on DEBUG in Debug builds only.

Calls that disappear when a symbol is missing

#define AUDIT
using System;
using System.Diagnostics;

Audit("Login: asha");
Trace("Loaded 3 orders");
Console.WriteLine("Main work done");

[Conditional("AUDIT")]
static void Audit(string message) => Console.WriteLine($"[AUDIT] {message}");

[Conditional("VERBOSE")]
static void Trace(string message) => Console.WriteLine($"[TRACE] {message}");

AUDIT is turned on at the top, so the Audit call runs and prints. VERBOSE is never turned on, so the Trace call disappears completely and prints nothing.

Try This

Delete the first line, #define AUDIT, and run again. Now both calls disappear.

Two important rules for [Conditional] methods:

  • The method must return void. A removed call cannot produce a return value.
  • #define lines must come before everything else in the file, even using statements.

In this browser editor, DEBUG is not defined. A method marked [Conditional("DEBUG")] will never run here, even though it runs in Visual Studio when you press F5. That is why the example uses its own AUDIT symbol.

Caller Information Attributes

When you write a log message, you often want to know where it came from. Typing the method name by hand is tedious, and it becomes wrong when you rename the method.

Caller information attributes tell the compiler: "Fill in this information for me automatically". You put them on optional parameters:

  • [CallerMemberName] tells the compiler: "Write the method or property name here".
  • [CallerLineNumber] tells the compiler: "Write the line number here".
  • [CallerFilePath] tells the compiler: "Write the file path here".
  • [CallerArgumentExpression("param")] tells the compiler: "Write the code text of the argument here".

The caller never passes these arguments. The compiler fills them in automatically each time the method is called.

A logger that knows who called it

using System;
using System.Runtime.CompilerServices;

var cart = new Cart();
cart.AddItem("Pen");
cart.Checkout();

static class Log
{
    public static void Info(string message,
        [CallerMemberName] string member = "",
        [CallerLineNumber] int line = 0)
    {
        Console.WriteLine($"[{member}, line {line}] {message}");
    }
}

class Cart
{
    public void AddItem(string name) => Log.Info($"Added {name}");
    public void Checkout() => Log.Info("Checking out");
}

AddItem and Checkout never type their own names. The compiler filled them in automatically.

CallerArgumentExpression

This attribute captures the code text of an argument. It helps guard methods print exactly which check failed.

Error messages that show the failing check

using System;
using System.Runtime.CompilerServices;

int quantity = 0;
decimal price = 499m;

try
{
    Guard.IsTrue(price > 0);
    Guard.IsTrue(quantity > 0);
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}

static class Guard
{
    public static void IsTrue(bool condition,
        [CallerArgumentExpression(nameof(condition))] string expression = "")
    {
        if (!condition)
            throw new ArgumentException($"Check failed: {expression}");
        Console.WriteLine($"Passed: {expression}");
    }
}

The error message says exactly which check failed: quantity > 0. Without the attribute, you would only know that some check returned false, without knowing which one.

[CallerMemberName] is the classic helper for INotifyPropertyChanged in WPF and MAUI. The property setter calls OnPropertyChanged() with no argument, and the attribute automatically fills in the property name.

Attribute Parameters

Attributes can have two kinds of parameters.

1. Positional parameters come from the attribute's constructor. You must provide them, and they go first in a specific order.

2. Named parameters set a public property or field. You can skip them if you want, and you write them as Name = value.

[Range(1, 10, ErrorMessage = "Pick 1 to 10")]
//     ^  ^   ^ named: sets the ErrorMessage property
//     positional: the constructor's (minimum, maximum)

One important rule: attribute arguments must be compile-time constants. This means you must know the value when you write the code, not when the program runs. You can use:

  • numbers, bool, char and strings,
  • enum values,
  • typeof(SomeType),
  • arrays of the above.

You cannot use new DateTime(...), a decimal value, or any variable.

Positional and named parameters

using System;
using System.Reflection;

var info = typeof(PaymentService).GetCustomAttribute<ServiceInfoAttribute>()!;
Console.WriteLine($"Name:    {info.Name}");
Console.WriteLine($"Version: {info.Version}");
Console.WriteLine($"Owner:   {info.Owner}");
Console.WriteLine($"Tags:    {string.Join(", ", info.Tags)}");

[ServiceInfo("Payments", 2, Owner = "Ravi", Tags = new[] { "billing", "upi" })]
class PaymentService { }

class ServiceInfoAttribute : Attribute
{
    // Positional: from the constructor
    public string Name { get; }
    public int Version { get; }

    // Named: public settable properties
    public string Owner { get; set; } = "unknown";
    public string[] Tags { get; set; } = Array.Empty<string>();

    public ServiceInfoAttribute(string name, int version)
    {
        Name = name;
        Version = version;
    }
}

Custom Attributes

A custom attribute is your own attribute class. You make one in three simple steps:

  1. Create a class that extends System.Attribute.
  2. End the name with Attribute so others know it is an attribute.
  3. Add a constructor for the values you need and properties for optional values.

That is it. The attribute does not do anything by itself. It only helps when code reads it.

A custom attribute for API permissions

using System;
using System.Reflection;

foreach (var method in typeof(OrdersController).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
    var permission = method.GetCustomAttribute<RequiresRoleAttribute>();
    string who = permission == null ? "anyone" : permission.Role;
    Console.WriteLine($"{method.Name,-12} -> {who}");
}

class OrdersController
{
    public void List() { }

    [RequiresRole("Manager")]
    public void Refund() { }

    [RequiresRole("Admin")]
    public void DeleteAll() { }
}

[AttributeUsage(AttributeTargets.Method)]
class RequiresRoleAttribute : Attribute
{
    public string Role { get; }
    public RequiresRoleAttribute(string role) => Role = role;
}

A web framework does the same thing. When a user makes a request, it finds the method that will handle it, reads the permission label, and checks if the user has permission to run it.

Attribute Targets

By default, an attribute can go almost anywhere. Usually that is wrong. A [RequiresRole] on an enum does not make sense.

[AttributeUsage] is an attribute you put on your attribute class. It says where your attribute is allowed. It controls three things:

SettingMeaningDefault
AttributeTargetsWhere can this attribute go?All
AllowMultipleCan it appear more than once on the same item?false
InheritedDo child classes see the attribute from the parent class?true

Combine targets with |, for example AttributeTargets.Class | AttributeTargets.Method.

AllowMultiple and Inherited

using System;
using System.Reflection;

foreach (var tag in typeof(Laptop).GetCustomAttributes<TagAttribute>())
    Console.WriteLine($"Laptop tag: {tag.Name}");

// Inherited = true, so GamingLaptop sees the base class label
var cat = typeof(GamingLaptop).GetCustomAttribute<CategoryAttribute>();
Console.WriteLine($"GamingLaptop category: {cat?.Name}");

[Category("Electronics")]
[Tag("portable"), Tag("work"), Tag("sale")]
class Laptop { }

class GamingLaptop : Laptop { }

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
class TagAttribute : Attribute
{
    public string Name { get; }
    public TagAttribute(string name) => Name = name;
}

[AttributeUsage(AttributeTargets.Class, Inherited = true)]
class CategoryAttribute : Attribute
{
    public string Name { get; }
    public CategoryAttribute(string name) => Name = name;
}

If you try to put [RequiresRole] on a class when it should only go on methods, the compiler stops you:

[RequiresRole("Admin")]          // error CS0592: Attribute 'RequiresRole' is not allowed
class Settings { }               // here. It can only go on 'method'.

Target Specifiers

Sometimes one line creates multiple things. An auto-property creates both a property and a hidden field. A method has a body and a return value.

When you need to pick which one gets the attribute, use a target specifier. Write it before the attribute name with a colon:

[field: NonSerialized]           // the hidden backing field, not the property
public string Cache { get; set; }

[return: NotNull]                // the method's return value
public string GetName() { ... }

[assembly: AssemblyVersion("2.1.0")]   // the whole assembly, at the top of a file

Reading Attributes With Reflection

Reflection has special methods to find attributes. You call them on Type, PropertyInfo, MethodInfo and similar classes.

MethodWhat it does
GetCustomAttribute<T>()Finds and returns the first attribute of type T, or nothing if there is no attribute
GetCustomAttributes<T>()Finds and returns all attributes of type T
IsDefined(typeof(T))Checks if an attribute exists (yes or no), without reading it

To use these methods, add using System.Reflection; at the top of your file.

Find every property marked as sensitive

using System;
using System.Reflection;

var user = new UserProfile
{
    Name = "Priya",
    Email = "priya@example.com",
    Password = "s3cret!",
    Pan = "ABCDE1234F",
};

foreach (var property in typeof(UserProfile).GetProperties())
{
    bool hide = property.IsDefined(typeof(SensitiveAttribute));
    object? value = hide ? "******" : property.GetValue(user);
    Console.WriteLine($"{property.Name,-8}: {value}");
}

class UserProfile
{
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
    [Sensitive] public string Password { get; set; } = "";
    [Sensitive] public string Pan { get; set; } = "";
}

[AttributeUsage(AttributeTargets.Property)]
class SensitiveAttribute : Attribute { }

This is how real programs keep data safe when logging. You mark a property [Sensitive] and the logger skips it.

Use IsDefined when you only care about yes or no. It is faster than GetCustomAttribute, because it does not create a new attribute object. GetCustomAttribute creates a new object each time, so if you need it many times, save it in a variable.

Attribute-Based Validation

Now let's use what you learned. You will build a simple validator that checks objects by reading their attributes.

Each rule is a custom attribute with an IsValid method. The validator does not know about each rule separately. It just finds all the rule attributes and asks each one: "Is this value okay?"

A tiny validation framework

using System;
using System.Collections.Generic;
using System.Reflection;

var good = new Registration { Name = "Asha", Age = 29, Email = "asha@example.com" };
var bad = new Registration { Name = "", Age = 12, Email = "asha.example.com" };

Print("good", Validator.Validate(good));
Print("bad", Validator.Validate(bad));

static void Print(string label, List<string> errors)
{
    Console.WriteLine(errors.Count == 0 ? $"{label}: valid" : $"{label}: {errors.Count} problem(s)");
    foreach (string e in errors) Console.WriteLine($"  - {e}");
}

class Registration
{
    [NotEmpty] public string Name { get; set; } = "";
    [Between(18, 120)] public int Age { get; set; }
    [NotEmpty, Contains("@")] public string Email { get; set; } = "";
}

static class Validator
{
    public static List<string> Validate(object target)
    {
        var errors = new List<string>();
        foreach (var property in target.GetType().GetProperties())
        {
            object? value = property.GetValue(target);
            foreach (var rule in property.GetCustomAttributes<RuleAttribute>())
            {
                if (!rule.IsValid(value))
                    errors.Add($"{property.Name} {rule.Message}");
            }
        }
        return errors;
    }
}

[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
abstract class RuleAttribute : Attribute
{
    public abstract string Message { get; }
    public abstract bool IsValid(object? value);
}

class NotEmptyAttribute : RuleAttribute
{
    public override string Message => "must not be empty";
    public override bool IsValid(object? value) => value is string s && s.Length > 0;
}

class BetweenAttribute : RuleAttribute
{
    private readonly int _min, _max;
    public BetweenAttribute(int min, int max) { _min = min; _max = max; }
    public override string Message => $"must be between {_min} and {_max}";
    public override bool IsValid(object? value) => value is int n && n >= _min && n <= _max;
}

class ContainsAttribute : RuleAttribute
{
    private readonly string _text;
    public ContainsAttribute(string text) => _text = text;
    public override string Message => $"must contain '{_text}'";
    public override bool IsValid(object? value) => value is string s && s.Contains(_text);
}

GetCustomAttributes<RuleAttribute>() finds every attribute that is a RuleAttribute. To add a new rule, you write one small class. The validator stays the same.

The Built-in Version: Data Annotations

.NET comes with a validation framework built in, called System.ComponentModel.DataAnnotations. ASP.NET Core and Blazor use it automatically.

Validation with DataAnnotations

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

var form = new ContactForm { Name = "", Email = "not-an-email", Age = 15 };

var results = new List<ValidationResult>();
bool ok = Validator.TryValidateObject(form, new ValidationContext(form), results, validateAllProperties: true);

Console.WriteLine($"Valid: {ok}");
foreach (var r in results)
    Console.WriteLine($"  - {r.ErrorMessage}");

class ContactForm
{
    [Required(ErrorMessage = "Name is required")]
    public string Name { get; set; } = "";

    [EmailAddress(ErrorMessage = "Email is not valid")]
    public string Email { get; set; } = "";

    [Range(18, 99, ErrorMessage = "Age must be 18 to 99")]
    public int Age { get; set; }
}

In real projects, use Data Annotations first. Only write your own attributes for checks the built-in ones do not handle.

Where Attributes Are Used

  • Web APIs: [HttpGet], [Route], [Authorize], [FromBody] in ASP.NET Core.
  • Validation: [Required], [Range], [EmailAddress] in forms and APIs.
  • JSON: [JsonPropertyName("first_name")], [JsonIgnore].
  • Databases: [Key], [Table("orders")], [MaxLength(100)] in Entity Framework.
  • Testing: [Fact], [Theory], [TestMethod].
  • Code generators: [GeneratedRegex], [LoggerMessage], [JsonSerializable].

Common mistakes

MistakeSymptomFix
No code reads the attributeNothing happensRead it with reflection
Variable in attribute argumentsCS0182 errorUse a const or a literal value
Missing using System.ReflectionCS1061 on GetCustomAttributeAdd the using statement
[Conditional("DEBUG")] in a Release buildMethod never runsCheck which symbols are defined
Attribute placed on wrong kind of memberCS0592 errorCheck the [AttributeUsage] specification
Same attribute twice without AllowMultipleCS0579 duplicate attribute errorSet AllowMultiple = true

What to Remember

  • An attribute is a label on code. Nothing happens until someone or something reads it.
  • Put attributes in square brackets. The word Attribute at the end is optional.
  • [Obsolete] warns people not to use your code. Add true to make it an error.
  • [Conditional("X")] erases a method call if symbol X is not defined. The method must return void.
  • Caller information attributes let the compiler fill in details like the method name or line number automatically.
  • Positional parameters come from the constructor; named parameters set properties.
  • Attribute arguments must be constants you know at write time, not variables.
  • Write a custom attribute by making a class that extends Attribute. Use [AttributeUsage] to limit where it can go.
  • Read attributes with GetCustomAttribute<T>() to get one, GetCustomAttributes<T>() to get all, or IsDefined for yes/no.
  • Validation systems are just attributes with code that reads them using reflection.

Next: Dynamic Types