Pydantic v1 to v2 Migration Gotchas the Official Guide Leaves Out
You did the Pydantic v2 migration by the book. You ran bump-pydantic, you read the official migration guide, you fixed the validators it flagged, and the app booted. Then two things happened that the guide never mentioned. A dependency blew up on an import of ModelMetaclass, and a test that had passed for two years started failing on a datetime comparison, with no error and no obvious reason.
Neither of these is in the migration guide, and that is not you missing a page. The maintainers confirmed at the time that these changes were not documented in the changelog or the guide. The v2 rewrite was enormous, and a handful of behaviour changes slipped through the net. If you are migrating, you will hit some of them, and the worst are the ones that do not raise an exception.
This is a field guide to the gaps, not a rehash of the guide. Start with bump-pydantic and the official document, absolutely, but expect these.
Two undocumented traps bite hardest.
from pydantic.main import ModelMetaclassno longer works. The (private) replacement isfrom pydantic._internal._model_construction import ModelMetaclass, but prefer public APIs over importing it at all.- v2 changed how datetime fields coerce naive and epoch values, so round-trip equality assertions can fail silently, without any error being raised.
Also: update_forward_refs() is now model_rebuild(), and use the pydantic.v1 compatibility shim to migrate incrementally rather than pinning pydantic<2 forever.
The exact error: cannot import name 'ModelMetaclass'
ImportError: cannot import name 'ModelMetaclass' from 'pydantic.main'
This usually surfaces from a third-party library, not your own code, which is why it feels like it came out of nowhere. Some dependency reached into pydantic.main to grab the metaclass so it could detect or subclass Pydantic models, and in v2 that symbol is gone from that module.
Why it moved
ModelMetaclass was never really a public, supported import; it lived in pydantic.main in v1 and a lot of libraries imported it anyway because it was the only way to hook into model construction. The v2 rewrite reorganised the internals and relocated the metaclass into a private module. Nothing about that was called out in the migration notes, so anyone depending on the old path got a hard ImportError on first run.
The fix
If you must import it, the working location in v2 is the internal module:
# v1
from pydantic.main import ModelMetaclass
# v2 (works, but it is private: the leading underscore is a warning)
from pydantic._internal._model_construction import ModelMetaclass
Treat that underscore seriously. It is a private path with no stability guarantee, so it can move again in a future minor release. If the import is in your own code, the durable fix is to stop needing the metaclass: use the public model_rebuild(), the typing utilities, or an isinstance check against BaseModel instead of metaclass gymnastics. If the import is in a dependency, upgrade that dependency to a version built for Pydantic v2; the maintainers have almost certainly fixed it, and reaching into private internals to patch someone else's library is a trap you will pay for at the next upgrade.
The silent one: datetime coercion changed
This is the dangerous gap, because there is no ImportError to lead you to it. In v1, Pydantic applied its own permissive parsing to datetime fields, and the way it handled naive values and epoch numbers was idiosyncratic. v2 replaced that with stricter, RFC 3339-oriented parsing. The practical result is that a value which round-tripped equal under v1 can come out of a v2 model with different timezone information: aware where it was naive, or a different offset than your test expected.
Your test does not raise. It either compares two datetimes that are now unequal and fails on the assertion, or, if one side ended up timezone-aware and the other naive, Python refuses to compare them and you get a TypeError from the comparison rather than from Pydantic. Both are confusing because the model "worked" and no validation error was raised.
from datetime import datetime, timezone
from pydantic import BaseModel
class Event(BaseModel):
at: datetime
# The value your field ends up holding may carry different tzinfo in v2
# than it did in v1 for the same input. This assertion can now fail:
e = Event(at="2024-01-01T00:00:00")
assert e.at == datetime(2024, 1, 1) # may fail, or raise if one side is aware
The fix is to stop relying on implicit coercion for anything you assert on. Be explicit about timezones at the boundary: decide whether your fields are aware or naive, normalise incoming values in a validator, and compare against values with matching tzinfo. If your domain is UTC, make every datetime aware and UTC on the way in, and your equality tests become deterministic again instead of depending on parser internals.
The renames that turn into errors under -W error
Several v1 methods are deprecated rather than removed, which means they still work but emit a DeprecationWarning. That is fine until your test suite runs with warnings promoted to errors (-W error or a pytest filterwarnings = error config), at which point a "working" call becomes a hard failure.
| Pydantic v1 | Pydantic v2 | Note |
|---|---|---|
Model.update_forward_refs() | Model.model_rebuild() | Deprecated alias, errors under -W error |
model.dict() | model.model_dump() | Old name deprecated |
model.json() | model.model_dump_json() | Old name deprecated |
Model.parse_obj() | Model.model_validate() | Old name deprecated |
from pydantic.main import ModelMetaclass | from pydantic._internal._model_construction import ModelMetaclass | Private, undocumented, may move again |
bump-pydantic catches most of the method renames automatically, which is exactly why it is worth running first. It does not catch behaviour changes like the datetime coercion, because there is no syntax to rewrite, only semantics that shifted.
Migrate incrementally with the v1 shim
The wrong reaction to all this is to pin pydantic<2 and stay there. That freezes you out of every library moving to v2 and turns into a bigger migration later, under more pressure. Pydantic ships a compatibility namespace so you can move module by module:
# Keep a stubborn model on v1 semantics temporarily while the rest moves to v2
from pydantic.v1 import BaseModel as BaseModelV1
class LegacyPayload(BaseModelV1):
# v1 validators and behaviour still apply here
...
This lets you install Pydantic v2, unblock everything that is already v2-ready, and carry the awkward models on the shim until you have time to port them properly. It is a bridge, not a destination; the goal is still to retire the pydantic.v1 imports. If your migration also touched how your app boots (a common casualty when import paths move), the debugging approach in fixing uvicorn's "Error loading ASGI app" pairs well with this.
Verify the migration actually holds
# 1. Fail on any deprecated v1 call, so nothing slips through as a warning
python -W error -c "import your_app"
# 2. Run the suite with warnings promoted to errors
pytest -W error
# 3. Grep for the private import so you know exactly where the debt is
grep -rn "pydantic._internal" your_app/
grep -rn "pydantic.v1" your_app/
Step 1 flushes out deprecated calls hiding behind warnings. Step 2 makes the datetime and coercion changes fail loudly in tests rather than silently in production, which is the entire point. Steps 3 lists your remaining private-import and shim debt so it is visible instead of forgotten. Pair this with tests that assert on concrete, timezone-explicit datetime values, the same discipline that catches subtle async test failures.
What people get wrong
Pinning pydantic<2 indefinitely. It feels safe and is the most expensive option. The ecosystem has moved; every month on v1 is a month of dependencies you cannot upgrade, and the eventual jump gets harder, not easier.
Assuming a clean bump-pydantic run means you are done. The tool rewrites syntax. It cannot see that a datetime now carries different tzinfo, or that a comparison will fail at runtime. A green migration tool and a green test suite are two different claims.
Reaching into _internal and forgetting about it. Private imports work until a patch release, then break with no deprecation cycle because they were never public. Every one is a landmine you planted for future-you.
When it is still broken
- A dependency, not your code, imports the old path. Trace the
ImportErrorstack to find which package. Upgrade that package rather than patching Pydantic's internals under it. - Datetimes still compare wrong after normalising. Check both sides of every assertion for
tzinfo. Mixed aware and naive values is the usual culprit, and it can raise aTypeErrorthat looks unrelated to Pydantic. - Custom validators behave differently. v2's validator model changed; a
@validatorported to@field_validatormay run at a different point in the pipeline. Read what value it actually receives now. - Serialisation output shifted.
model_dump()can produce different types than v1'sdict()for some fields. If a downstream consumer breaks, diff the serialised output between versions before assuming your model is wrong.
The migration is worth doing; v2 is faster and stricter and the ecosystem has committed to it. Just go in knowing the guide is a floor, not a ceiling. The loud failures you will fix in minutes. It is the silent datetime change that deserves your attention, because a model that quietly returns a slightly different value is the kind of bug that reaches production wearing a passing test as a disguise.
Frequently asked questions
- Where do I import ModelMetaclass from in Pydantic v2?
- It moved out of pydantic.main into a private module. The working import is 'from pydantic._internal._model_construction import ModelMetaclass', but the leading underscore means it is private and can move again without a deprecation cycle. Prefer public APIs (model_rebuild, an isinstance check against BaseModel) in your own code, and upgrade third-party libraries rather than patching them.
- Why does my datetime test pass on Pydantic v1 but fail on v2?
- v2 replaced v1's permissive datetime parsing with stricter RFC 3339-oriented coercion, so a naive or epoch value can end up with different timezone info than it had under v1. Your equality assertion then fails, or raises a TypeError if one side became timezone-aware and the other stayed naive. No validation error is raised, which is why it is easy to miss.
- Should I pin pydantic<2 to avoid the migration?
- No. Pinning to v1 freezes you out of every library that has moved to v2 and makes the eventual migration harder under more time pressure. Install v2 and use the pydantic.v1 compatibility shim to carry stubborn models on v1 semantics module by module, so you can unblock the rest of your dependencies immediately and port the awkward parts on your own schedule.
- Does bump-pydantic handle the whole v2 migration?
- It handles the mechanical renames like dict() to model_dump() and update_forward_refs() to model_rebuild(), which is why it is worth running first. It cannot catch behaviour changes such as the datetime coercion difference, because there is no syntax to rewrite, only semantics that shifted. A clean bump-pydantic run and a correct migration are two different claims; run your tests with warnings promoted to errors.