Migrating openai-python From v0 to v1
11 min read · updated August 11, 2026
The v1 release of openai-python is not a set of deprecations you can work through gradually. The module-level API was removed in one step, so the first call your upgraded process makes raises, and it raises in a way that a broad except block will swallow. This is the rewrite, in the order that keeps a large codebase runnable while you do it.
What actually changed
Four things, and it helps to hold them separately because they fail separately. The library moved from module-level functions to an instantiated client. Configuration moved from module globals to constructor arguments. Responses stopped being dictionaries and became Pydantic models. And the exception classes moved out of openai.error into the top-level openai namespace, with one of them renamed.
Only the first of those is loud. The second fails at import or at the first request with a missing key. The third fails on a subscript, and the fourth does not fail at all in the place you would want it to — it fails by your handler never matching, so an error you were catching and retrying now propagates.
The library ships a codemod for the first two. OpenAI’s v1 migration guide documents openai migrate, which runs grit-based AST transforms over your tree, and it is worth running — but the guide itself records that it does not catch every dictionary-access pattern, and on Windows it needs WSL. Treat it as the first eighty percent.
Step one: build a client
In v0 the module was the client. You set openai.api_key once at import time and every call read it. In v1 you construct an object:
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("OPENAI_API_KEY"), # read from this env var by default
base_url="https://api.openai.com/v1", # was openai.api_base
organization=os.environ.get("OPENAI_ORG_ID"),
timeout=20.0,
max_retries=3,
)The defaults are worth knowing before you pick numbers: the README documents a default of two retries and a ten-minute timeout. Ten minutes is a sensible default for a library that also handles long batch and file operations, and a terrible one for a request behind a web handler, so a chat client is one of the places where you should set it explicitly rather than inherit it.
Per-request overrides no longer mean per-call keyword arguments for transport settings. There is a with_options method that returns a copy of the client:
client.with_options(timeout=5.0).chat.completions.create(...) client.with_options(max_retries=5).chat.completions.create(...)
Two more constructors exist and matter for the migration plan. AsyncOpenAI replaces the old acreate methods, which is a larger change than it sounds: in v0 you could switch one call to async by changing create to acreate, and in v1 the async surface is a different client object, so async and sync call sites need different dependencies threaded to them. AzureOpenAI replaces the old pattern of setting openai.api_type and openai.api_version as module globals.
Step two: move the call sites
The mapping is mechanical. Every resource became an attribute path on the client:
openai.ChatCompletion.create(...) -> client.chat.completions.create(...) openai.Completion.create(...) -> client.completions.create(...) openai.Embedding.create(...) -> client.embeddings.create(...) openai.Image.create(...) -> client.images.generate(...) openai.Moderation.create(...) -> client.moderations.create(...) openai.File.list(...) -> client.files.list(...) openai.FineTuningJob.create(...) -> client.fine_tuning.jobs.create(...)
Note the one that is not a pure rename: Image.create became images.generate. A find-and-replace on .create will miss it and leave you an AttributeError on a code path that probably has no test.
The request body itself is largely unchanged — model, messages, temperature, max_tokens, stop, stream all survive with the same meaning, because that part is the HTTP API rather than the library. This is the reassuring half of the migration: your prompts, your message arrays and your parameter choices carry over untouched.
client.responses.create. That is a separate decision from this migration and not part of it — the v0→v1 jump lands you on client.chat.completions, and moving from there is a later, optional piece of work.Step three: responses are models, not dicts
This is the change the codemod is documented as being weakest on, and the one that produces failures furthest from the upgrade. A v0 response was a dict-like object, so every access in your codebase is probably a subscript:
# v0 text = completion["choices"][0]["message"]["content"] usage = completion["usage"]["total_tokens"] blob = json.dumps(completion) # v1 text = completion.choices[0].message.content usage = completion.usage.total_tokens blob = completion.model_dump_json()
Two consequences beyond the syntax. First, json.dumps on the response object no longer works, which matters because logging a raw completion is extremely common; model_dump_json() and model_dump() are the replacements. Second, a Pydantic model raises AttributeError for a field that does not exist, where a dict raised KeyError — so any code that did completion.get(...) or caught KeyError around optional fields needs rethinking rather than translating.
Streaming changes shape in the same way. Each chunk is a model, and the field you want is chunk.choices[0].delta.content, which is None on chunks that carry no text — the first chunk announcing the role, and the chunks of a tool call. Concatenating without that check is the classic post-migration crash:
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say hello."}],
stream=True,
)
for chunk in stream:
piece = chunk.choices[0].delta.content
if piece is not None:
print(piece, end="")If you want the whole surface of what a chunk contains rather than just the text, the general treatment is in the chunk format page; nothing about the wire format changed in this migration, only how you reach into it.
Step four: the exception renames
The migration guide records one rename explicitly: openai.error.InvalidRequestError became openai.BadRequestError. The rest moved namespace without changing name, and the README documents the set by status code — BadRequestError for 400, AuthenticationError for 401, PermissionDeniedError for 403, NotFoundError for 404, UnprocessableEntityError for 422, RateLimitError for 429, and InternalServerError for 5xx, with APIConnectionError for a request that never got a status. The base classes are APIError, APIStatusError, APIConnectionError and APITimeoutError.
Why this one is dangerous: openai.error does not exist as a module attribute in v1 at all, so a handler written as except openai.error.RateLimitError does not quietly stop matching — it raises AttributeError while Python is evaluating the except clause, during handling of the original exception. The traceback you get names the wrong problem, and if the enclosing frame has a bare except Exception for resilience, your retry loop now treats every rate limit as a permanent failure.
# v0
except openai.error.RateLimitError:
backoff_and_retry()
except openai.error.InvalidRequestError as exc:
log_and_give_up(exc)
# v1
except openai.RateLimitError:
backoff_and_retry()
except openai.BadRequestError as exc:
log_and_give_up(exc)Grep for openai.error as a separate pass from everything else. It is the one change with no failing import and no failing test, and a test that exercises the retry path is the only thing that catches it before production does.
Doing it in an order that keeps the build green
- Pin what you have. Put
openai==0.28.1in the lockfile and commit, so there is a known-good state to return to and so nobody else’s upgrade lands in the middle of yours. - Inventory the surface before changing anything:
grep -rn "openai\." --include="*.py". You want three lists — call sites, response subscripts, andopenai.errorhandlers — because they get fixed by three different edits. - Add a thin wrapper module now, while still on v0, that every call site goes through. One function per operation, returning plain values your code owns rather than the library’s objects. This step is optional and it is the one that pays: it turns steps four and five from a codebase-wide edit into a single-file edit.
- Upgrade in a branch, run
openai migrate, and read the diff rather than accepting it. It will rewrite construction and call sites well and leave dictionary access behind. - Fix the subscripts and the exception handlers by hand from the two lists you made. Run the test suite with warnings escalated to errors so deprecations from other libraries in the same upgrade do not hide in the output.
- Exercise a real request against a staging key, including one that fails: a 400 from a deliberately bad parameter, and a stream you cancel halfway. Import errors are found by tests; handler mismatches are only found by a failure.