Last Updated: May 17, 2026
An enum is a value type that gives a name to each value in a fixed set of related constants. Instead of passing around the magic number 2 to mean "shipped" or the string "Cancelled" to mean a cancelled order, you pass OrderStatus.Shipped or OrderStatus.Cancelled, and the compiler keeps track of which values are valid. The result is code that reads like English, refactors safely, and rejects nonsense values before they become bugs.
An enum is a named set of integer constants packaged as a value type. Each member of the enum is one of those constants, and every value of the enum type is either one of the declared members or an integer cast to the enum type. The CLR stores the value as a plain integer at runtime; the name exists for the compiler, for IntelliSense, for ToString, and for the engineer reading the code.
The smallest useful enum looks like this:
Two things worth noticing. First, status is typed as OrderStatus, not as int. The compiler will refuse to let you assign a string, a bool, or even a plain int to it without an explicit cast. Second, Console.WriteLine printed Shipped, not the underlying number, because the default ToString for enums returns the member name when one matches.
A non-flags enum should be named with a singular noun: OrderStatus, not OrderStatuses; PaymentMethod, not PaymentMethods. The reason is that a variable of type OrderStatus holds exactly one status at a time. (Flags enums, which can hold combinations, follow different naming conventions.)
Each of these declares a closed set of options. Adding a new payment method means editing the enum, recompiling, and (importantly) revisiting every switch that handles PaymentMethod to decide what to do with the new value. That visibility is part of why enums are useful: a new option doesn't quietly slip through.
Under the hood, every enum has an underlying integer type. The default is int, which gives each member a 4-byte representation and a value range from negative to positive two billion. You can pick a different integer type after the enum name with a colon. The valid choices are byte, sbyte, short, ushort, int, uint, long, and ulong.
Picking byte here is a deliberate choice. ShippingSpeed will never have two billion options, and storing it in one byte instead of four matters when the type is used inside a struct that's allocated in large arrays or serialized across the network. For most enums, int is fine and you should leave the default alone.
By default, the first member is 0, the second is 1, the third is 2, and so on. You can assign explicit values to control the numbering. This is common when the integer values are stable contracts, like database codes or wire format identifiers:
The gap between Delivered = 3 and Cancelled = 99 is fine. The values don't need to be contiguous; they just need to be valid for the underlying type. Once a value is assigned to a member, it shouldn't change in future versions, because anything stored on disk or sent over the network will still carry the old number. A database row with status = 2 will keep meaning Shipped only as long as Shipped stays mapped to 2.
Mixing explicit and implicit values is legal. Any member without an explicit value gets the previous member's value plus one:
This is a small footgun. The reader sees Clothing = 10 and assumes Home and Toys got their own explicit numbers too. Always assign every value explicitly when you care about the numbers, and leave them all implicit when you don't.
Every enum has a default value, and that default is 0 regardless of which members are declared. If you create an uninitialized field of an enum type, or default an enum in a generic context, or read an enum value from a freshly allocated struct, the value will be the underlying integer 0 cast to the enum type. If 0 is not one of your declared members, you now have an enum variable whose value is not in the set of valid options.
order.Status is the integer 0, which doesn't match any member, so ToString falls back to printing the raw number. The order is not Placed, not Shipped, not Delivered, and not Cancelled. It's in a state the enum never declared.
The fix is a rule that pays for itself across an entire codebase: always include a member with value 0, and name it something that makes sense as a default. None, Unknown, or Unspecified are the usual choices.
With None = 0 in place, an uninitialized OrderStatus defaults to OrderStatus.None. That's still not a meaningful business state, but it's an explicit "we haven't set this yet" that the rest of the code can check for. Code that processes orders can guard with if (order.Status == OrderStatus.None) throw ... and reject the order before doing any real work.
An enum is integer-shaped at runtime, but the compiler treats it as its own distinct type at compile time. You can convert in both directions with an explicit cast. The compiler refuses implicit conversions to keep you from mixing enum values with arbitrary integers by accident.
The cast in either direction is a zero-cost operation: there's no allocation, no boxing, no method call. The CLR is treating the same bits as a different type label.
The catch is that casting from int to an enum doesn't validate anything. Any integer in the underlying type's range can be cast to the enum, valid member or not.
rogue holds the value 42, which is a perfectly legal int but is not a declared OrderStatus member. The code compiles, runs, and produces an enum value that fails every comparison. When you cast from an integer source you don't fully trust, validate the result before you use it. The next section covers the two tools for that.
Cost: Casting between an enum and its underlying integer compiles to a no-op (the same bits are reinterpreted). The cost is in the validation you have to do yourself; the cast itself is free.
The System.Enum type provides static helpers for parsing strings, validating values, and iterating members. The ones you'll use most are Enum.TryParse<T>, Enum.IsDefined<T>, Enum.GetValues<T>(), and Enum.GetNames<T>(). The generic versions (<T>) were added in .NET 5 and are the preferred form; the older non-generic overloads return object and require boxing.
Enum.TryParse converts a string to an enum value and returns a bool indicating success. It does not throw on failure, which makes it the right choice for parsing input you don't control (query strings, request bodies, CSV columns).
Two details to keep in mind. First, TryParse is case-sensitive by default, so "shipped" won't match Shipped unless you pass ignoreCase: true. Second, TryParse will happily parse a numeric string into the enum even if the resulting value doesn't match any declared member. Enum.TryParse<OrderStatus>("42", out var x) returns true and sets x to the integer 42. That's TryParse choosing flexibility over strict validation. If you need to reject undeclared values, combine it with IsDefined.
Enum.IsDefined<T>(value) returns true if the value matches a declared member of the enum. It's the tool for validating an integer cast or a parsed result against the real set of valid members.
IsDefined is doing a real check at runtime: it iterates the declared members and compares. For a small enum that cost is negligible. For an enum used in a hot loop, you might prefer a switch or a guard at the boundary where untrusted data enters the system, and trust the type downstream.
One important caveat: IsDefined does not understand [Flags] combinations. For a flags enum, the value Read | Write is a valid combination but is not a declared member, so IsDefined returns false. This is one of the gotchas that the [Flags] lesson covers in detail.
Cost: Enum.IsDefined does a linear scan of the enum's members under the hood. For enums with a handful of values it's cheap. Avoid calling it in a tight loop on large enums; validate at the system boundary instead.
Enum.GetValues<T>() returns an array of every declared member's value. Enum.GetNames<T>() returns an array of every declared member's name as a string. Both are useful for building dropdown lists, iterating all states, or logging.
GetValues<T> returns a strongly typed T[], so status in the loop is typed as OrderStatus without a cast. The pre-.NET 5 non-generic Enum.GetValues(typeof(OrderStatus)) returns an Array of boxed object values, which forced an unbox on every iteration. The generic version avoids that cost.
C# has two convenient ways to turn an enum value into a string: nameof and ToString. They look similar and they often produce the same text, but they are not the same operation and they fail in different ways.
nameof(OrderStatus.Placed) is a compile-time operation. The compiler replaces the expression with the literal string "Placed" before the program ever runs. It costs nothing at runtime, it can't return a number, and if you rename Placed your IDE updates the nameof along with every other reference.
status.ToString() is a runtime operation. It looks up the integer value of status in the enum's metadata and returns the matching member name, or the integer as a string if no member matches. That last detail is the one that bites people. If you're logging an enum value to track a bug, and the value is 42 instead of Shipped, the log will say 42 and you'll have a clue that something cast an invalid integer into the enum.
nameof is the right choice when you have a literal enum member in source code and you want its name: logging keys, JSON property names, route names, anything where the identifier is fixed at compile time. ToString is the right choice when you have a variable whose value isn't known until runtime.
A switch over an enum is the standard way to branch on the value. Both the classic switch statement and the newer switch expression work, and either one composes well with the compiler's coverage checks.
A few details to point out. The _ arm at the end catches anything that doesn't match a declared member, which includes the rogue cast from (OrderStatus)42. Without it, the compiler issues a warning that the switch is non-exhaustive, and at runtime the expression would throw a SwitchExpressionException if it ever hit a value it couldn't match. Putting the default arm in is cheap insurance against future enum members and invalid integer casts.
The same logic written as a classic switch statement looks like this:
Both compile to nearly identical IL when the cases are simple. The switch expression reads better for value-returning logic; the switch statement reads better when each case has side effects or multiple statements. Pattern matching with enums goes further than this (guards, relational patterns, combined patterns).
A state diagram makes the legal transitions between OrderStatus members visible:
The diagram is what the enum means to the business. The enum itself is just the set of node labels; the rules about which transitions are allowed live in whatever class owns the order. Encoding the transitions in code (a method like Ship() that throws if the current state isn't Placed) keeps the enum honest.
const int or StringsA common alternative to enums is a set of const int values in a static class, or worse, raw string literals scattered through the code. Both work, in the sense that they compile and run, and both lose to enums on every dimension that matters for real code.
The three options compare like this:
| Concern | Strings | const int | Enum |
|---|---|---|---|
| Type safety at compile time | None: any string accepted | Weak: any int accepted | Strong: only OrderStatus accepted |
| Typos caught at compile time | No: "Shippd" compiles | Partial: wrong const compiles | Yes: OrderStatus.Shippd is an error |
| IntelliSense lists valid values | No | Only inside the class | Yes |
| Rename refactor is safe | No: must search/replace | Mostly yes | Yes |
| Iteration over all values | Manual list | Manual list | Enum.GetValues<T>() |
| Display name without lookup | Trivial | Manual map | ToString or nameof |
| Underlying numeric stable | N/A | Yes | Yes |
The string version has no compile-time safety at all. A typo in a call site is caught only when the matching logic runs and silently misses. The const int version is better, but the method still takes an int, so any int from anywhere in the program is acceptable, including ones that don't match any of the constants. The enum version is the only option where the compiler enforces that the value is actually one of the declared possibilities.
The wins compound. A method signature with OrderStatus status documents itself: anyone reading it knows exactly what values are legal. An enum-typed local variable shows up in IntelliSense with the full set of options as soon as you type the dot. A switch over an enum gives you a compiler warning when a new member is added but a case wasn't, which is the single best argument for picking an enum over either alternative.
There are a few cases where strings or const int win. Values that arrive from an external source where you don't control the set (HTTP status codes are a fixed list defined by RFCs, country codes are an ISO standard) are sometimes better modeled as strings or ints to match the source's vocabulary. Values that need to be open-ended and extensible at runtime (plugin tags, user-defined labels) can't be enums, because enums are closed at compile time. For everything else, enum wins.
An enum is a value type. It lives on the stack when used as a local, gets copied on assignment, and has no heap allocation when stored as a struct field. Comparing two enums for equality with == is a bitwise compare of the underlying integer; there's no virtual call and no boxing.
Assigning b = a copied the value. Changing b later didn't touch a. This is the standard value-type behavior, and it applies to every enum.
In older .NET versions, calling Enum.HasFlag or the non-generic Enum.GetValues boxed the enum value, which made these methods surprisingly expensive in hot loops. Modern .NET (.NET 6 and later) has generic, allocation-free versions: Enum.HasFlag<T> (when used with the generic constraint) and Enum.GetValues<T>(). Sticking to the generic helpers keeps enum operations free of allocation surprises.
Cost: Boxing an enum (assigning it to object or passing it as object) allocates a small heap object every time. If you need to print an enum value, prefer string interpolation or ToString directly, which the JIT has optimized to avoid boxing for value types in most cases.
A field of an enum type defaults to the integer 0 regardless of what members are declared. This snippet is what happens when an enum forgets to reserve 0 for a sensible default.
The bug is subtle. The order's Status was never set, so it defaulted to the integer 0, which isn't a declared member. IsActive returns true because 0 is neither Cancelled nor Delivered, so an order that was never properly placed is reported as active. Logging the order prints 0 instead of a status name, which is the only hint that something is off.
Fix:
Two changes work together. The enum declares None = 0 so the default is a meaningful "not set yet" value. The IsActive method treats None as inactive, so an order that wasn't initialized is no longer reported as active. The field initializer = OrderStatus.None is redundant (the default is already 0) but it makes the intent explicit, which is the kind of small clarity that pays for itself in code review.
int. You can pick byte, short, long, or any of the unsigned variants after a colon when storage size matters.0 cast to the enum type, regardless of declared members. Always include a None = 0 member (or Unknown, Unspecified) so the default is a meaningful state and never a silent invalid value.Enum.IsDefined<T> to confirm a cast result is a real member.Enum.TryParse<T> converts a string to an enum value without throwing on failure. It is case-sensitive by default, accepts numeric strings, and pairs well with Enum.IsDefined for strict validation.Enum.GetValues<T>() and Enum.GetNames<T>() (generic since .NET 5) iterate the declared members allocation-free, in declaration order.nameof(OrderStatus.Placed) is a compile-time string "Placed" that renames safely with the IDE. status.ToString() is a runtime lookup that falls back to the underlying number if no member matches.switch expression over an enum should include a _ arm to handle future members and rogue integer casts; without it the compiler warns and the runtime can throw SwitchExpressionException.const int and string constants on every dimension the compiler can help with: type safety, IntelliSense, refactoring, exhaustiveness checks, and built-in reflection helpers.