- Published on
Protobuf Edition 2026: Three Tightened Defaults, and the Schema Size Limits protoc Finally Enforces
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — An RC That Shipped Four Days Before the Announcement
- Editions, in One Paragraph
- Where protoc Started Accepting edition = "2026"
- Default Change 1: enforce_naming_style Moves to STYLE2026
- Default Change 2: default_symbol_visibility Moves to STRICT
- Default Change 3: enforce_proto_limits — This Is the Real Change
- protoc Turns the Checks On; the Runtime Turns Them Off
- What's New (Briefly)
- Honest Tradeoffs and Limitations
- So, What Should You Actually Do Now
- Closing
- References
Introduction — An RC That Shipped Four Days Before the Announcement
On July 13, 2026, the Edition 2026 announcement went up on protobuf.dev's news page. That was three days ago. But looking at the GitHub release list, v36.0-rc1 had already been published four days before that — on July 9. While the announcement was saying it plans to release "in 36.x, in Q3 2026," the first RC of that very 36.x was already out.
This post is not a summary of that announcement. The announcement is short, some of it is out of step with the actual implementation, and it leaves out entirely what is arguably the single most interesting change. So this post is what I found reading the actual source of the v36.0-rc1 tag directly — descriptor.proto, descriptor.h, code_generator.h, command_line_interface.cc.
The conclusion first. Edition 2026 adds no new syntax at all. The announcement's last line stands as written — "There is no new or removed grammar in Edition 2026." Instead, it tightens three defaults. And one of them is something protoc has not done in more than fifteen years.
Editions, in One Paragraph
Protocol Buffers itself and a comparison of binary serialization formats have already been covered, so here is just a quick note on Editions. In the old scheme you wrote syntax = "proto2" or syntax = "proto3" at the top of the file, and that single token decided a bundle of otherwise-unrelated behaviors at once — field presence, whether enums are open or closed, default packing, and more. Editions break that bundle apart into individual features, and the top of the file now just carries a year, like edition = "2024". Each feature has a different default per edition, and can be overridden individually at the file level or on a specific element if needed.
The key point is this — nothing changes unless you bump the edition. Defaults are tied to the edition, so a file that stays at edition = "2023" never experiences Edition 2026's default changes at all. A new edition is a device for declaring "this is the better default from now on" — not a device for breaking existing files.
Looking at the Edition enum in descriptor.proto, the released editions are listed like this.
// Editions that have been released. The specific values are arbitrary and
// should not be depended on, but they will always be time-ordered for easy
// comparison.
EDITION_2023 = 1000;
EDITION_2024 = 1001;
EDITION_2026 = 1002;
There is no 2025. The values are consecutive — 1000, 1001, 1002 — so nothing was skipped; a 2025 edition was simply never made. (The comment itself insists the values are arbitrary and not to be depended on, only time-ordered, so there is no reason to read meaning into the gap between years either.)
Where protoc Started Accepting edition = "2026"
Exactly which release "Edition 2026 shipped" from is confirmed by a single line in code_generator.h.
// The maximum edition supported by protoc.
constexpr auto ProtocMaximumEdition() { return Edition::EDITION_2026; }
Tracking this value across release tags makes the boundary clear.
v32.0 (2025-08-14) ProtocMaximumEdition() = EDITION_2024
v33.0 (2025-10-15) ProtocMaximumEdition() = EDITION_2024
v34.0 (2026-02-25) ProtocMaximumEdition() = EDITION_2024
v35.0 (2026-05-19) ProtocMaximumEdition() = EDITION_2024
v35.1 (2026-06-11) ProtocMaximumEdition() = EDITION_2024 <- current stable
v36.0-rc1 (2026-07-09) ProtocMaximumEdition() = EDITION_2026 <- first here
In other words, v36.0-rc1 is the first protoc that can accept edition = "2026" at the top of a file. Feed a 2026 file to any earlier protoc and it is rejected for exceeding the maximum supported edition. And as of today (2026-07-16), stable is still v35.1, and v36.0 is an RC, not GA.
Checking the past the same way confirms the track record too. The Edition 2024 announcement said on June 27, 2025 that it would ship "in 32.x, in Q3 2025," and v32.0 came out on August 14, 2025, with ProtocMaximumEdition() bumped to EDITION_2024 right there. As promised. This announcement uses the same format — "36.x, Q3 2026" — and the RC is already out.
Default Change 1: enforce_naming_style Moves to STYLE2026
The default table in descriptor.proto looks like this.
enum EnforceNamingStyle {
ENFORCE_NAMING_STYLE_UNKNOWN = 0;
STYLE2024 = 1;
STYLE_LEGACY = 2;
STYLE2026 = 3;
}
// edition_defaults:
// EDITION_LEGACY -> STYLE_LEGACY
// EDITION_2024 -> STYLE2024
// EDITION_2026 -> STYLE2026
The announcement describes the rule like this — inside a message that has a field x, has_x, set_x, get_x, clear_x, and x_value are banned, and a field cannot be named descriptor.
But reading the actual implementation (descriptor.cc, commit dated 2026-04-07), it turns out to be more precise than the announcement. The banned prefixes are the four has_, get_, set_, clear_, and the banned suffix is a single _value — but it is not an unconditional ban, it is conditional.
if (absl::StartsWith(name, prefix)) {
absl::string_view without_prefix = name;
without_prefix.remove_prefix(prefix.size());
if ((message->FindFieldByName(without_prefix) != nullptr ||
message->FindOneofByName(without_prefix) != nullptr)) {
*error = absl::StrCat("should not begin with ", prefix,
" if a field named ", without_prefix,
" exists. This can cause collisions in "
"generated code.");
return false;
}
}
has_x is only an error when a matching x actually exists in the same message. If there is no x, a field literally named has_x passes fine. So this is less a naming convention than a collision check — it targets precisely the situation where the generated has_x() accessor and a has_x field would collide on the same symbol in generated code. And when looking for the match, it searches not just fields but oneof names too (FindOneofByName), and the check itself applies to both fields and oneofs. descriptor, by contrast, is banned unconditionally.
The two-tier opt-out is also missing from the announcement. There are two error-message constants in the source.
constexpr absl::string_view kNamingStyleOptOutMessage =
" (features.enforce_naming_style = STYLE_LEGACY can be used to opt out of "
"this check)";
constexpr absl::string_view kNamingStyleCollisionsOptOutMessage =
" (features.enforce_naming_style = STYLE2024 can be used to opt out of "
"this check)";
Dropping to STYLE2024 turns off only the collision check that is new in 2026, while keeping the 2024 style check. To turn everything off, you have to drop to STYLE_LEGACY. In practice, the lever you actually reach for during migration is usually the former.
One last, minor but amusing wart. In the enum above, STYLE_LEGACY is 2 and STYLE2024 is 1, so numeric order and strictness order disagree. That is why the helper that decides "is this style at least this strict" has a special case baked in.
bool IsStyleOrGreater(const DescriptorT* descriptor,
FeatureSet::EnforceNamingStyle style) {
return internal::InternalFeatureHelper::GetFeatures(*descriptor)
.enforce_naming_style() >= style &&
// Required because STYLE_LEGACY comes after STYLE2024 in enum
// definition.
internal::InternalFeatureHelper::GetFeatures(*descriptor)
.enforce_naming_style() != FeatureSet::STYLE_LEGACY;
}
Enum value ordering cannot be changed after the fact, so this is a scar left by working around it in code. A project all about managing protocol evolution getting tripped up by the evolution of its own enum — and honestly, it is normal for a trace like this to survive.
Default Change 2: default_symbol_visibility Moves to STRICT
Edition 2024 introduced the export / local keywords and the default_symbol_visibility feature, with a default of EXPORT_TOP_LEVEL — top-level symbols export, nested symbols are local. Edition 2026 bumps this to STRICT.
The comment in descriptor.proto spells out exactly what each value means.
// Default pre-EDITION_2024, all UNSET visibility are export.
EXPORT_ALL = 1;
// All top-level symbols default to export, nested default to local.
EXPORT_TOP_LEVEL = 2;
// All symbols default to local.
LOCAL_ALL = 3;
// All symbols local by default. Nested types cannot be exported.
// With special case caveat for message { enum {} reserved 1 to max; }
// This is the recommended setting for new protos.
STRICT = 4;
Three things change under STRICT — every symbol defaults to local, the export/local keywords can only be used on top-level message/enum declarations, and attaching either keyword to a nested symbol is a syntax error. That is where it differs from LOCAL_ALL. LOCAL_ALL only flips the default to local but still lets you resurrect a nested type as export; STRICT removes that escape hatch entirely.
The escape hatch is not entirely gone, though — there is one carve-out for an idiom people used to avoid polluting the C++ namespace. When a top-level message is local and every field is reserved, a nested export enum is permitted.
local message MyNamespaceMessage {
export enum Enum {
MY_VAL = 1;
}
// Ensure no fields are added to the message.
reserved 1 to max;
}
The intent is to allow this only when the message is used purely as a namespace shell — one that has pinned down reserved 1 to max; to guarantee it will never hold a single field. The condition being this tight reads as a deliberate attempt to keep this from becoming a general-purpose workaround.
The stated rationale for this change is the 1-1-1 best practice — one message per .proto file, so that importing that file only pulls in that one message, keeping dependency bloat down. Why this is a size problem in the first place: as the symbol visibility docs explain, protobuf bundles descriptor data at the granularity of a FileDescriptorSet/FileDescriptor, so every definition in a file — plus everything that file imports — comes along as one bundle.
Default Change 3: enforce_proto_limits — This Is the Real Change
And this is the feature that is not mentioned even once in the July 13 announcement. The announcement's "Changes to Existing Features" section covers only the two above, and its "New Features" section lists only a JSON custom string for enum values, a C++ namespace option, splitting out C++ custom options, and C# nullable reference types. Yet descriptor.proto in v36.0-rc1 has this.
message ProtoLimitsFeature {
enum EnforceProtoLimits {
PROTO_LIMITS_UNKNOWN = 0;
// Default pre-EDITION_2026: there are no limit enforcement at the protoc
// level. Practical limits still exist, but they will tend to fail while
// compiling protoc-generated code, and these limits tend to be language
// or toolchain specific.
LEGACY_NO_EXPLICIT_LIMITS = 1;
// A set of limits enforced by Edition 2026 by default. For a detailed
// list of all the limits please consult the Edition 2026 documentation.
PROTO_LIMITS2026 = 2;
}
}
The second sentence of that comment is the entire reason this feature exists. The limits were always there. protoc just did not know about them, so failures surfaced in the wrong place. The comment in descriptor.h is more blunt about it.
// Enforce protobuf limits at the descriptor level. Pre Edition 2026, there
// is no limit enforcement in the protobuf compiler when parsing proto files.
// As a result, it is possible to write a protobuf file that compiles in
// protoc, and code generation works as intended, but the code generation
// output is uncompilable because it runs into language-specific or
// compiler-specific limits.
//
// Starting in Edition 2026, certain limits will start to be enforced. The
// goal of this enforcement is to help ensure that any file that is accepted
// by the protobuf compiler will be compilable on standard tool chains.
void EnforceProtoLimits(bool enforce) { enforce_proto_limits_ = enforce; }
The situation where "it compiles in protoc, code generation runs as intended, but the generated code itself will not compile." Anyone who has run protobuf at any real scale knows exactly what this sentence means — you keep adding values to an enum, and one day javac starts throwing constant-related errors, and it takes a while before you realize your .proto file is the cause.
It gets even more interesting when you look at how these limits have been managed up to now. protobuf.dev has a document called Proto Limits, and that document introduces itself like this.
This information is a collection of discovered limitations by many engineers, but is not exhaustive and may be incorrect/outdated in some areas.
In other words, it is a collection of things a lot of engineers discovered the hard way, and it admits up front that it is neither exhaustive nor guaranteed accurate. The actual numbers inside read like this — the hard cap on fields per message is 65,535, but a message that is all single fields (like Booleans) breaks around "~2100 (proto2)" or "~3100 (proto3, without optional)," and for enums "the lowest limit is ~1700 in Java." Folklore with tildes attached.
Edition 2026 turns this folklore into numbers protoc enforces. The constants in descriptor.h are these.
// Edition 2026 introduces default limits for proto files as the descriptor gets
// built from protoc. To opt out of these limits for edition 2026, you may use
// features.enforce_proto_limits = LEGACY_NO_EXPLICIT_LIMITS.
inline constexpr int kLimit2026FieldsPerMessage = 1500;
inline constexpr int kLimit2026OneofsPerMessage = 1000;
inline constexpr int kLimit2026FieldsPerOneof = 1200;
inline constexpr int kLimit2026ValuesPerEnum = 1700;
The four numbers agree across three places — the header constants above, the commit message that introduced this feature (dated 2026-05-14), and the enforce_proto_limits section of the Feature Settings for Editions doc. All three give the same values.
Edition 2024 and below Edition 2026
Fields per message No limit (at protoc level) 1500
Oneofs per message No limit 1000
Fields per oneof No limit 1200
Values per enum No limit 1700
What stands out here is the enum's 1700. It is exactly the number the Proto Limits doc gave as "~1700 in Java." The field limit of 1500 is also set lower than the doc's "~2100 (proto2)." So the new limits do not look like arbitrary round numbers — they look like lines drawn inside where things actually break. (That said, I have not found any public document from the protobuf team stating the rationale behind these exact figures. All I have confirmed is that the numbers line up; the causal link is my inference.)
And here is a spot that will sting in practice. This opt-out cannot be set at the file level. Looking at this feature's targets in descriptor.proto, there is ENUM, MESSAGE, FIELD, ONEOF — and no FILE.
optional ProtoLimitsFeature.EnforceProtoLimits enforce_proto_limits = 9 [
retention = RETENTION_SOURCE,
targets = TARGET_TYPE_ENUM,
targets = TARGET_TYPE_MESSAGE,
targets = TARGET_TYPE_FIELD,
targets = TARGET_TYPE_ONEOF,
feature_support = {
edition_introduced: EDITION_2026
},
...
];
The Feature Settings doc says the same thing in a code comment — "Must be set at the level of the oversized message, enum, or oneof as this feature is not allowed at the file level." You cannot write one line at the top of the file to switch it off wholesale; you have to go find every oversized message, enum, and oneof and attach the override individually. When you move a large legacy schema to Edition 2026, this is exactly the work that shows up on the ticket.
One more thing. That edition_introduced: EDITION_2026 in feature_support means this feature cannot be configured at all before Edition 2026. The two previous features carry edition_introduced: EDITION_2024, so you can touch them even on an Edition 2024 file — but the size-limit check is only reachable once you move to 2026. There is no way to flip it on ahead of time to prepare.
protoc Turns the Checks On; the Runtime Turns Them Off
These three checks do not run all the time. Looking at command_line_interface.cc, the protoc CLI turns them on explicitly.
descriptor_pool->EnforceSymbolVisibility(true);
descriptor_pool->EnforceNamingStyle(true);
descriptor_pool->EnforceProtoLimits(true);
DescriptorPool's member defaults, on the other hand, are off — descriptor.h initializes them as enforce_proto_limits_ = false; and enforce_symbol_visibility_ = false;. So code that builds a descriptor pool directly at runtime (dynamic schema loading, for example) does not automatically get these checks. That is reasonable — there is nothing to gain from rejecting, at runtime, a descriptor that already made it through protoc once.
And all three features are retention = RETENTION_SOURCE. They only carry meaning in the source and are not carried into the generated descriptor. This is a compile-time lint, not runtime behavior.
One thing worth stressing here — nothing about the wire format changes. Editions have never touched encoding, and Edition 2026 is no exception. The byte stream, driven by field numbers and wire types, stays exactly as it was. Nothing in this post affects compatibility between services that are already deployed.
What's New (Briefly)
What the announcement lists under "New Features" is mostly a matter of individual language bindings.
- JSON custom strings for enum values. Where the existing
json_namerenamed the key, this now renames the value too —FOO_BAR = 5 [(pb.enumvalue.json).string = "custom_string_here"];. The announcement cites collision avoidance, migration support, and satisfying external requirements as the reasons. - The C++
namespaceoption.option (pb.file.cpp).namespace = "clock_time";lets you set the generated binding's namespace independently of the.protopackage. - C++ custom options moving out of
descriptor.proto. C++-only options move into a separate file, pulled in viaimport option "google/protobuf/cpp_features.proto";. This section also mixes in mentions ofrepeated_type = PROXYandRepeatedFieldProxy, but the announcement's markdown has a broken code fence here, so which section that content actually belongs to is ambiguous from the source text alone. - C# nullable reference types. Opt-in, and there is a matching entry in the
v36.0-rc1release notes too — "Advertise that C# supports Edition 2026, and move C# Nullable Reference Type support into that edition."
Honest Tradeoffs and Limitations
This is not GA yet. As of today, stable is v35.1 and v36.0 is rc1. The announcement itself says as much.
These describe changes as we anticipate them being implemented, but due to the flexible nature of software some of these changes may not land or may vary from how they are described in this topic.
"May not land, may differ from what is described." Every number and every behavior in this post is as of the v36.0-rc1 tag, and any of it can change by GA.
The announcement and the code disagree. enforce_proto_limits is in the code, and it is in the docs (Feature Settings), but it is not in the July 13 announcement. Judge Edition 2026 by the announcement alone and you miss its single highest-impact change entirely. And the other way around, the Feature Settings doc's own example for enforce_proto_limits writes that "an override is required to keep an oversized message" and then shows code that sets PROTO_LIMITS2026 (turning it on, not off). The docs are freshly written and still rough. The normative facts are safer to confirm directly in descriptor.proto's targets/edition_defaults.
Language support ships separately. Most of what is covered here concerns protoc and the C++ runtime. Each language runtime has to advertise Edition 2026 support before you can actually use it (which is exactly why the C# item gets its own release-note entry), and third-party implementations or toolchains that do not go through protoc are a separate question entirely.
The payoff is not the visible kind. None of these three features makes messages smaller or parsing faster. All three are compile-time lint. What you get is "the thing that would have broken somewhere weird later now breaks as a clear error now" — and whether that is worth something to your team depends entirely on the team.
So, What Should You Actually Do Now
For most people, there is nothing to do right now. If you are on syntax = "proto2" / syntax = "proto3", Edition 2026 has nothing to do with you today. In descriptor.proto's default table, proto2/proto3/Edition 2023 all sit at the legacy value for all three features (STYLE_LEGACY, EXPORT_ALL, LEGACY_NO_EXPLICIT_LIMITS), and stay there unless you bump the edition. No reason to rush.
If you are already on Edition 2024 and thinking about looking at 2026, this is the right order to work in. The naming-collision check can be cleared out ahead of time with STYLE2024, and visibility can be prepared with explicit export/local annotations. The size limits, though, as covered above, have no way to be turned on before 2026 — so the only option is to just go count whether your schema has any message over 1500 fields or any enum over 1700 values. If it does, that will be most of your migration cost — because you cannot flip it off at the file level.
If you have no reason to move to Edition 2026 but just want the checks, there is mostly no way, or only a partial one. This is the intended outcome of how editions are designed — an edition is exactly a bundle of defaults sold by year.
Where it is worth it, on the other hand, is clear. Organizations with large schemas, generating for multiple languages, who have lived through "why does only the Java build break." For teams like that, having protoc's acceptance of a schema actually guarantee it compiles on the standard toolchain is worth quite a lot. That is precisely the goal the descriptor.h comment states — "any file that is accepted by the protobuf compiler will be compilable on standard tool chains."
Closing
Edition 2026 is not a flashy release. Zero new syntax, zero wire-format changes, zero performance improvements. Instead it tightens three defaults — banning name collisions, defaulting symbols to local, and drawing a line on schema size for the first time.
And that is exactly what the Editions mechanism was built to do. In the proto2/proto3 era, even knowing "this default is wrong" gave you no way to fix it — changing it would break .proto files across the entire world the moment you did. Editions tie defaults to a year, leaving old files on old defaults while letting new files start with better ones. Edition 2026 looks close to the first real case of that machine actually turning — three at once, each with its own opt-out, without touching a single existing file.
The part I like best is the size limits. A number that used to sit in a wiki-style doc with a tilde in front of it — "discovered by many engineers, not exhaustive, may be wrong" — has become four constants the compiler enforces. The moment folklore turns into a spec is usually quiet, and usually looks exactly like this.
That said, as of today this is still an RC, the announcement leaves out one of its own features, and the docs' example has a bug in it. Check descriptor.proto for the truth. Not the announcement.
References
- Changes Announced on July 13, 2026 — the Edition 2026 announcement (raw markdown source)
- descriptor.proto @ v36.0-rc1 — the Edition enum and the default tables for
enforce_naming_style,default_symbol_visibility,enforce_proto_limits - descriptor.h @ v36.0-rc1 — the
kLimit2026*constants and theEnforceProtoLimitscomment - code_generator.h @ v36.0-rc1 —
ProtocMaximumEdition() - Feature Settings for Editions — the per-feature default table by edition
- Symbol Visibility — the precise rules and carve-out for STRICT
- Proto Limits — the pre-Edition-2026 "discovered limits" doc
- Protocol Buffers v36.0-rc1 release (2026-07-09)
- Changes Announced on June 27, 2025 — the Edition 2024 announcement (for the track-record comparison)
- gRPC & Protocol Buffers Complete Guide 2025: The New Standard for Microservices Communication (related post)
- Binary Serialization Complete Guide 2025: Protobuf, Thrift, Avro, MessagePack, FlatBuffers, Cap'n Proto — Choosing Between Performance and Flexibility (related post)