DataAnnotations are attributes that declare validation rules on ViewModel properties. They serve a dual purpose: they drive ASP.NET Core’s server-side ModelState validation (run on every request before the action body executes) and they generate data-val-* HTML attributes that drive client-side validation (run in the browser before form submission). One set of annotations covers both validation layers, eliminating duplication. Understanding the full set of available attributes and how they map to HTML5 validation and ModelState errors is essential for building correct form workflows.
Core DataAnnotations
using System.ComponentModel.DataAnnotations;
public class CreatePostViewModel
{
// ── Required fields ────────────────────────────────────────────────────
[Required(ErrorMessage = "Title is required.")]
[StringLength(200, MinimumLength = 5,
ErrorMessage = "Title must be between 5 and 200 characters.")]
[Display(Name = "Post Title")] // label text in views using asp-for
public string Title { get; set; } = string.Empty;
[Required(ErrorMessage = "Content is required.")]
[MinLength(50, ErrorMessage = "Content must be at least 50 characters.")]
[DataType(DataType.MultilineText)] // renders as textarea with asp-for
public string Body { get; set; } = string.Empty;
[Required]
[StringLength(100)]
[RegularExpression(@"^[a-z0-9-]+$",
ErrorMessage = "Slug must contain only lowercase letters, numbers, and hyphens.")]
public string Slug { get; set; } = string.Empty;
// ── Optional with format validation ────────────────────────────────────
[EmailAddress(ErrorMessage = "Please enter a valid email address.")]
[StringLength(200)]
public string? ContactEmail { get; set; }
[Url(ErrorMessage = "Please enter a valid URL.")]
[StringLength(500)]
public string? ExternalLink { get; set; }
// ── Numeric range ──────────────────────────────────────────────────────
[Range(1, 10, ErrorMessage = "Priority must be between 1 and 10.")]
public int Priority { get; set; } = 5;
// ── Date ──────────────────────────────────────────────────────────────
[DataType(DataType.Date)]
[Display(Name = "Publish Date")]
[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
public DateTime? ScheduledFor { get; set; }
// ── Comparison (password confirmation) ────────────────────────────────
[Required, DataType(DataType.Password)]
public string Password { get; set; } = string.Empty;
[Compare(nameof(Password), ErrorMessage = "Passwords do not match.")]
[DataType(DataType.Password)]
public string ConfirmPassword { get; set; } = string.Empty;
}
ModelState and sets ModelState.IsValid = false. The action body still executes — you must check if (!ModelState.IsValid) at the start. The [ApiController] attribute on Web API controllers automatically returns 400 without executing the action body, but in MVC controllers you must check manually.[Display(Name = "...")] to set human-readable field names for validation error messages and form labels. Without it, the property name is used: “Title is required” becomes “PostTitle is required” if the property is named PostTitle. [Display(Name = "Post Title")] makes the error read “Post Title is required” — professional and user-friendly. The asp-for Label Tag Helper reads the [Display] attribute automatically to generate the label text.[Required] on a reference type (string, class) is not enough when nullable reference types (NRT) are enabled. [Required] prevents empty strings and null; NRT prevents the compiler warning. Both are needed: use [Required] for runtime/client validation and initialise with a non-null default or use string? to communicate that the field is genuinely optional. A property declared as string with NRT enabled but without [Required] is nullable reference types compliant but allows empty/null at runtime via model binding.Display and Format Annotations
// ── [Display] — controls label text and grouping ──────────────────────────
[Display(Name = "Author Email",
Description = "Will be shown on the author page",
GroupName = "Contact",
Order = 1)]
public string AuthorEmail { get; set; } = string.Empty;
// ── [DisplayFormat] — controls how values are formatted in views ───────────
[DisplayFormat(DataFormatString = "{0:C2}", // currency: $1,234.56
NullDisplayText = "N/A", // shown when value is null
ApplyFormatInEditMode = false)] // don't apply format in edit fields
public decimal? Price { get; set; }
// ── [DataType] — provides semantic type hint for Tag Helpers ─────────────
[DataType(DataType.Password)] // renders as <input type="password">
[DataType(DataType.EmailAddress)] // renders as <input type="email">
[DataType(DataType.Date)] // renders as <input type="date">
[DataType(DataType.PhoneNumber)] // renders as <input type="tel">
[DataType(DataType.MultilineText)] // renders as <textarea>
[DataType(DataType.Currency)] // formatting hint (not input type)
Common Mistakes
Mistake 1 — Using [Required] without checking ModelState.IsValid
❌ Wrong — annotation added but action proceeds with invalid data:
public IActionResult Create(CreatePostViewModel m)
{
// [Required] ran but was invalid — ModelState.IsValid is false
await _service.CreateAsync(m); // creating with empty required fields!
✅ Correct — always check if (!ModelState.IsValid) return View(model); first.
Mistake 2 — Generic error messages without field context
❌ Wrong — “The field is invalid” tells the user nothing.
✅ Correct — always set ErrorMessage with specific guidance: “Title must be between 5 and 200 characters.”