Why WPF Input Limits Such as MaxLength Do Not Apply to Values Set from Code or Bindings

Limits such as MaxLength and DisplayDateStart do not stop values from code or bindings. Three paths are measured on .NET 10, and checks move to the view model.

Overview

If a database column holds 50 characters, the TextBox gets MaxLength="50".
If deliveries are accepted only within a period, the DatePicker gets DisplayDateStart and DisplayDateEnd.
Input limits on the screen are a common way to keep values within range.
Yet when saved data is loaded, or when values imported from a file flow in through a binding, values beyond these limits appear on the screen as they are and go on to be saved.

This article tries seven common input limits on three paths: user input, assignment from code, and a bound value.
A table shows which path each limit applies to.
It then shows an implementation that moves validation and rounding into the view model, so that values beyond the limits are stopped reliably.
All values in the figures were measured by running the code on .NET 10 / Windows 11.


Prerequisites / Environment


Problem

The example is a screen that edits customer details.
The screen has these input limits.

<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
         MaxLength="50" />
<DatePicker SelectedDate="{Binding DeliveryDate}"
            DisplayDateStart="{Binding FirstDeliveryDate, Mode=OneWay}"
            DisplayDateEnd="{Binding LastDeliveryDate, Mode=OneWay}" />
<Slider Value="{Binding Volume, Mode=TwoWay}"
        Minimum="0" Maximum="100"
        IsSnapToTickEnabled="True" TickFrequency="10" />

The intent is a name of at most 50 characters, a delivery date within the period, and a volume from 0 to 100 in steps of 10.
DisplayDateStart and DisplayDateEnd bind two-way by default, and when the period is held in read-only properties, the default binding throws InvalidOperationException when the DataContext is set or when the window is shown, whichever comes later (the third figure, under Notes).
The bindings that only pass the period from the view model therefore specify Mode=OneWay.
As long as the user works with the keyboard and the calendar, the screen mostly behaves as intended.
The 51st character is not accepted, dates outside the period cannot be picked in the calendar, and the slider moves in steps of 10.

The trouble starts when a value arrives by a path other than the user’s input.
Typical cases include the following.

Each value appears on the screen as it is, and if the user presses Save, it reaches the save logic still beyond the limit.


The Common Advice

The standard answer to “how do I limit the number of characters” is to set MaxLength.
The Microsoft Learn reference for TextBox.MaxLength lists postal codes and phone numbers, and also keeping the text within the maximum length of the corresponding database column.
Other controls have properties that their references describe as restricting what the user does.
Calendar.DisplayDateStart, which the DatePicker.DisplayDateStart reference points to, keeps the user from scrolling to or selecting dates outside the range, and Slider.IsSnapToTickEnabled moves the thumb to the closest tick mark.
This article takes up the following seven settings, which are easy to write with the intent of keeping values within range.

What to protect Setting
Maximum number of characters TextBox.MaxLength
Uppercase text TextBox.CharacterCasing="Upper"
Maximum password length PasswordBox.MaxLength
Date range DatePicker.DisplayDateStart / DisplayDateEnd
Values in fixed steps Slider.IsSnapToTickEnabled with TickFrequency
Upper bound Slider.Maximum
A tab that cannot be selected TabItem.IsEnabled="False"

As a way to restrict what the user enters, these are correct.
The mistake is to read them as constraints on the property value, and to expect them to protect values that come from code or a binding too.


Why It Does Not Work

Each limit was tried on the three paths.
For user input, keyboard focus was moved to the control, and keys were sent through WPF’s InputManager and characters through TextCompositionManager.
For the calendar, the test read whether the day button outside the range was enabled, and tab selection was tried by calling Select through UI Automation, the interface that assistive technologies use.
Values from code and from bindings were set on controls shown in a window.

Table of input limits tried on three paths. A TextBox with MaxLength 5 becomes abcde when abcdefgh is typed, but stays abcdefgh when set from code or bound. A TextBox with CharacterCasing Upper becomes HELLO when hello is typed, but stays hello from code and from a binding. A PasswordBox with MaxLength 8 keeps 8 characters when 10 are typed, but keeps all 10 when Password is set from code, and PasswordBox has no PasswordProperty to bind. A DatePicker whose display range is April 10 to 20 disables the April 5 button in the calendar, but typing 4/5/2026 in the text box selects 2026-04-05, and code and a binding also give 2026-04-05. Afterwards DisplayDateStart drops to 2026-04-05 on every path, and after the date is set from code the April 7 button outside the range is enabled in the calendar. A Slider that snaps to ticks of 10 goes from 50 to 60 with the Right arrow key, but 23.4 from code and from a binding stays 23.4. A Slider with Maximum 100 goes to 100 with the End key, 150 from code becomes 100 and returns to 150 when Maximum is raised to 200, and a bound 150 shows 100 on the Slider while the source stays 150. The second TabItem with IsEnabled False throws ElementNotEnabledException on UI Automation Select and the selection stays 0, but SelectedIndex from code and from a binding selects 1 and shows Page 2.
Measured on .NET 10 / Windows 11. User input is keys and characters sent through InputManager and TextCompositionManager; for the calendar, the enabled state of the day button was read, and for the tab, UI Automation's Select was called. The DatePicker was tried with the en-US culture. The DisplayDateStart afterwards row shows the state after the out-of-range date in the row above came in, and the April 7 button was checked only after setting the date from code. Keys cannot produce a value above the maximum, so the End key in the Slider Maximum row only shows that the value stops at the maximum. The binding in that row specifies Mode=TwoWay.

Only the Slider’s Maximum brought values from code and bindings into range on the screen.
Assignment from code got past six of the seven limits.
Of the six limits that could be bound, which excludes the PasswordBox, bound values got past five on the screen and all six in the source.

For MaxLength, CharacterCasing, and PasswordBox.MaxLength, this is by design.
Each is defined as a limit on characters the user enters, not on the property value.
The MaxLength reference defines it as the maximum number of characters that can be entered manually, and states that it does not affect characters added programmatically.
The CharacterCasing reference has the same note, and the PasswordBox.MaxLength reference states that it has no effect when Password is changed from code.

For the Slider’s snapping, the reference does not say whether the path makes a difference.
In the measurement, the arrow key snapped to a tick, while 23.4 set from code or a binding stayed 23.4.

The DatePicker restricts only part of the user input.
The day buttons outside the range are disabled, but a date outside the range typed into the text box became the SelectedDate as it was.
Furthermore, whichever path brought in the out-of-range date, DisplayDateStart dropped to that date.
After 2026-04-05 was set from code, the calendar had the April 7 button, which is outside the range, enabled as well.
The Calendar.DisplayDateStart reference also states that setting SelectedDate before DisplayDateStart sets DisplayDateStart to the same value.
Only the start side was measured, but the Calendar.DisplayDateEnd reference says the same for dates after the end.
On a screen that has loaded an out-of-range value, even the calendar does not keep the range.

A TabItem with IsEnabled="False" rejects selection through UI Automation with ElementNotEnabledException.
Setting SelectedIndex, however, selected the disabled tab, and its content (SelectedContent) became Page 2.
That a click with the real mouse cannot select a disabled tab either is measured on the TabItem demo page.


When the Limits Do Apply

In the table, only the Slider’s Maximum brought values from code and bindings into range on the screen.
It is not part of input handling; it coerces Value itself into the range.
As the table shows, 150 set from code became 100, and it went back to 150 when Maximum was raised to 200.
The control keeps the value that was set and only constrains the effective value to the range.
This coercion is described in Dependency property callbacks and validation.

In the measurement, however, the coerced value was not written back to the bound source.
Even with Mode=TwoWay, the source stayed at 150 while the Slider on the screen showed 100.
The screen and the view model disagree, and the view model’s 150 goes on to the save logic, so this does not protect the value either.

A control’s input limit can be trusted to guarantee a value only when both of the following hold:

Few input fields in business applications meet these conditions.
A screen that edits saved values fails the first one by its nature.
The reliable approach is therefore to validate the range in the view model and keep the control’s limits as an aid to input.


Implementation

Validating the length in the view model

The length of the name is validated with INotifyDataErrorInfo.
Each time Name is set, the setter checks the length, records an error if it is too long, and raises ErrorsChanged.
Values from code and values from a binding both always pass through this setter.

using System.Collections;
using System.ComponentModel;
using System.Runtime.CompilerServices;

public sealed class CustomerViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
    public const int MaxNameLength = 50;

    private string _name = "";
    private readonly List<string> _nameErrors = new();

    public string Name
    {
        get => _name;
        set
        {
            _name = value;
            OnPropertyChanged();
            _nameErrors.Clear();
            if (value.Length > MaxNameLength)
            {
                _nameErrors.Add($"Name must be at most {MaxNameLength} characters.");
            }

            ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Name)));
            OnPropertyChanged(nameof(HasErrors));
        }
    }

    public bool HasErrors => _nameErrors.Count > 0;

    public IEnumerable GetErrors(string? propertyName) =>
        propertyName == nameof(Name) ? _nameErrors : Array.Empty<string>();

    public event PropertyChangedEventHandler? PropertyChanged;

    public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;

    private void OnPropertyChanged([CallerMemberName] string? name = null) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

A value that is too long is kept as an error rather than truncated, so that loaded data is not changed silently.
The user decides what to fix, and saving is allowed only while HasErrors is false.
Changes to HasErrors are also raised through PropertyChanged, so a Save button whose IsEnabled is bound to the inverse of HasErrors follows it (third row of the second figure).
WPF has no built-in converter that inverts a bool, so a converter like the following is needed.

using System.Globalization;
using System.Windows.Data;

public sealed class InvertBooleanConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => !(bool)value;

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => !(bool)value;
}

The Save button’s IsEnabled is bound to HasErrors through this converter.
local is the XAML prefix for the namespace that holds the converter.

<Window.Resources>
    <local:InvertBooleanConverter x:Key="InvertBooleanConverter" />
</Window.Resources>

<Button Content="Save"
        IsEnabled="{Binding HasErrors, Converter={StaticResource InvertBooleanConverter}}" />

The button is disabled while HasErrors is true, and enabled again once the length is fixed and it becomes false.

MaxLength stays in the XAML.
The keyboard can no longer enter more than 50 characters, so user input never goes past the limit.
The value should match CustomerViewModel.MaxNameLength.

<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
         MaxLength="50" />

The default of ValidatesOnNotifyDataErrors is true.
A binding to a source that implements INotifyDataErrorInfo reflects its errors in the TextBox’s Validation.HasError without setting it.

Rounding to the range and step in the setter

For some values, such as a volume, it is more natural to round an out-of-range value than to reject it.
In that case, the setter clamps the value to the range and rounds it to the step.
NaN cannot be clamped, so it is ignored and the current value is kept.

using System.ComponentModel;

public sealed class VolumeViewModel : INotifyPropertyChanged
{
    private double _volume;

    public double Volume
    {
        get => _volume;
        set
        {
            if (double.IsNaN(value))
            {
                return;
            }

            double clamped = Math.Clamp(value, 0, 100);
            _volume = Math.Round(clamped / 10, MidpointRounding.AwayFromZero) * 10;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Volume)));
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
}

PropertyChanged is raised with the rounded value, so the Slider also shows the rounded value.
The XAML can stay as the Slider in “Problem”.

Checking both paths

Both view models were tried with assignment from code and with user input.
For the figure, the maximum length of the name was changed to 5.
To check the view model’s rule alone, the Slider was widened to 0–200, snapping was turned off, and SmallChange was set to 5.
The Slider itself does not round the value, so if the source and the screen show a rounded value, the view model rounded it.
To see the value the Slider sent to the source on each key, the setter was given extra code, for the measurement only, that records the value it received (it is not in the implementation above).

Table of the view model checks. A view model that validates the length to at most 5 with INotifyDataErrorInfo gives HasErrors True and Validation.HasError True on the TextBox when abcdefgh is set from code. Typing abcdefgh into a TextBox with MaxLength 5 gives abcde and HasErrors False. After abcdefgh is loaded, HasErrors is True, typing x at the end leaves abcdefgh unchanged, and after one Backspace HasErrors is still True. A Save button whose IsEnabled is bound to the inverse of HasErrors is False when abcdefgh is set from code and True after abcde, and goes from False to True when a loaded abcdefgh is cut to abcde with three Backspaces. A view model that clamps to 0 to 100 gives 100 on both the source and the Slider for 150 from code, and 100 on both when the End key sends 200 from the Slider. A view model that rounds to steps of 10 gives 20 on both for 23.4 from code, and 60 on both when the Right arrow key from 50 sends 55.
Measured on .NET 10 / Windows 11. In the name rows, a view model with a limit of 5 characters is bound to a TextBox with MaxLength 5 using UpdateSourceTrigger=PropertyChanged, without setting ValidatesOnNotifyDataErrors. In the Save button row, IsEnabled is bound to HasErrors through a converter that inverts a bool. The Slider has Minimum 0, Maximum 200, no snapping, and SmallChange 5, and is bound TwoWay to Volume. The sent value is what the setter received before rounding, recorded for the measurement only; the implementation in this article does not record it.

The rounding applied both to values from code and to values sent by the Slider’s key operations.
150 and 23.4 became 100 and 20; the 200 sent by the End key and the 55 sent by the Right arrow key became 100 and 60; in every case the source and the Slider agreed.
The mismatch seen in the first figure when relying on the Slider’s Maximum alone, with the source at 150 and the screen at 100, did not occur.
A name that was too long, set from code, gave HasErrors True, and Validation.HasError on the TextBox was True as well.
The Save button followed the changes of HasErrors, both for values from code and for the user’s edits.


Notes

The following figure shows the results of binding the period.
The default two-way binding and OneWay were tried with DisplayDateStart.
Read-only properties were tried with both DisplayDateStart and DisplayDateEnd in four ways: setting the source directly, setting the DataContext while shown, setting the DataContext before showing, and setting the DataContext on a parent element before showing.

Table of DisplayDateStart and DisplayDateEnd bound to a view model. With DisplayDateStart bound to a source of 2026-04-10 by the default TwoWay binding and with Mode=OneWay, setting SelectedDate to 2026-04-05 from code lowers DisplayDateStart to 2026-04-05, the source stays 2026-04-10, the binding is kept, and setting the source to 04-12 makes DisplayDateStart 2026-04-12. The default binding of either DisplayDateStart or DisplayDateEnd to a read-only property throws InvalidOperationException naming the read-only property: in SetBinding when the source is set directly, at the moment the DataContext is set on a shown DatePicker, and, when the DataContext is set before showing, whether on the DatePicker itself or on a parent element that it inherits from, not at that moment but when the window is shown.
Measured on .NET 10 / Windows 11. The first two rows are the values after SelectedDate is set to 2026-04-05 from code. The value after the source is set to 04-12 is DisplayDateStart after SelectedDate is reset to null and the source is set to 2026-04-12. "read-only property" means the exception message named the read-only property as the reason. In the parent's DataContext rows, the DataContext was set on a parent element and inherited by the DatePicker; in the other rows, it was set on the DatePicker itself.

Summary

TextBox.MaxLength, CharacterCasing, PasswordBox.MaxLength, DisplayDateStart, IsSnapToTickEnabled, and TabItem.IsEnabled all restrict what the user does; none of them constrains the property value itself.
Assignment from code gets past all of them, bound values get past the ones that can be bound, and the DatePicker does not even stop input from its text box.
The Slider’s Maximum coercion, the only one in the table that brought values from code into range on the screen, was not written back to the bound source.

The responsibility for a value’s range is split as follows.