Keras 3 keeps the high-level modeling API but separates project code from the old TensorFlow-bound Keras 2 runtime surface. A migration should move imports to the standalone keras package first, then prove that the same model can train, save, and export under the backend the project will run.
The TensorFlow backend is the lowest-risk first target for old tf.keras projects because existing datasets, tensors, and serving systems can stay in place while import paths and artifact formats change. The backend must be selected before the first Keras import, and later multi-backend cleanup should replace TensorFlow-only symbolic operations with keras.ops.
The main compatibility changes are import paths, GPU JIT behavior, native .keras model files, and SavedModel export. Keep a rollback path for production code until the smoke test, project tests, and serving artifact checks pass in a clean environment.
Related: How to install Keras with pip
Related: How to set the Keras backend
Related: How to use legacy Keras 2 with TensorFlow
$ python -m pip install --upgrade keras tensorflow
Use the same virtual environment, container image, or lock-file workflow that will run the migrated project.
Related: How to install Keras with pip
$ export KERAS_BACKEND=tensorflow
Keras reads KERAS_BACKEND during process startup. Restart notebooks, workers, shells, and application servers after changing it.
Related: How to set the Keras backend
# Keras 2 from tensorflow import keras from tensorflow.keras import layers # Keras 3 import keras from keras import layers
# Keras 2 model = tf.keras.Sequential([tf.keras.layers.Dense(2)]) callback = tf.keras.callbacks.EarlyStopping(patience=2) # Keras 3 model = keras.Sequential([keras.layers.Dense(2)]) callback = keras.callbacks.EarlyStopping(patience=2)
Keep direct TensorFlow imports such as import tensorflow as tf only for TensorFlow-specific data pipelines, SavedModel checks, or operations that are still intentionally tied to the TensorFlow backend.
inputs = keras.Input(shape=(4, 1), name="features") x = keras.ops.squeeze(inputs, axis=-1) outputs = keras.layers.Dense(2, activation="softmax")(x) model = keras.Model(inputs, outputs)
Calls such as tf.squeeze(inputs) fail when inputs is a KerasTensor. Use keras.ops for graph-building operations that should work across Keras backends.
model.compile( optimizer="adam", loss="categorical_crossentropy", jit_compile=False, )
Keras 3 can enable JIT compilation on GPU. Keep the default when the model passes tests, and set jit_compile=False for custom layers or operations that fail under XLA.
Related: How to disable JIT compilation in Keras
model.save("migration-smoke.keras") loaded = keras.models.load_model("migration-smoke.keras")
In Keras 3, model.save() is for native .keras files or legacy .h5 files, not TensorFlow SavedModel directories.
Related: How to save and load a Keras model
model.export("migration-smoke-savedmodel", format="tf_saved_model")
Use model.export() for TensorFlow Serving, tf.saved_model.load(), TFLite, or other inference runtimes that consume SavedModel artifacts.
Related: How to export a Keras model as a SavedModel
Related: How to load a SavedModel in Keras with TFSMLayer
import os import shutil from pathlib import Path os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "2") import keras from keras import layers import numpy as np import tensorflow as tf keras.utils.set_random_seed(42) native_path = Path("migration-smoke.keras") export_dir = Path("migration-smoke-savedmodel") if native_path.exists(): native_path.unlink() if export_dir.exists(): shutil.rmtree(export_dir) x_train = np.array( [ [0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], ], dtype="float32", ) y_train = np.array( [ [1.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.0, 1.0], ], dtype="float32", ) sample = np.array([[1.0, 0.0, 0.0]], dtype="float32") model = keras.Sequential( [ keras.Input(shape=(3,), name="features"), layers.Dense(4, activation="relu", name="hidden"), layers.Dense(2, activation="softmax", name="risk_score"), ] ) model.compile(optimizer="adam", loss="categorical_crossentropy", jit_compile=False) history = model.fit(x_train, y_train, epochs=2, verbose=0) before = model(sample, training=False).numpy() model.save(native_path) loaded = keras.models.load_model(native_path) after = loaded(sample, training=False).numpy() model.export(export_dir, format="tf_saved_model", verbose=False) reloaded_artifact = tf.saved_model.load(export_dir) served = reloaded_artifact.serve(tf.constant(sample)).numpy() print(f"backend={keras.config.backend()}") print(f"final_loss={history.history['loss'][-1]:.4f}") print(f"native_file={native_path}") print(f"native_load_match={np.allclose(before, after, atol=1e-6)}") print(f"savedmodel_dir={export_dir}") print(f"savedmodel_endpoint=serve") print(f"served_shape={served.shape}")
Replace the small Sequential model with one migrated model entry point after the smoke test works. Keep the standalone keras imports, backend selection, native save/load check, and SavedModel export check.
$ KERAS_BACKEND=tensorflow python migrate_keras3_smoke.py backend=tensorflow final_loss=0.8695 native_file=migration-smoke.keras native_load_match=True savedmodel_dir=migration-smoke-savedmodel savedmodel_endpoint=serve served_shape=(1, 2)
native_load_match=True proves the native .keras file reloaded with matching predictions. The serve endpoint and output shape prove the SavedModel export can be called by a TensorFlow serving path.
$ rm -rf migration-smoke.keras migration-smoke-savedmodel migrate_keras3_smoke.py
Keep the real migrated source changes, lock-file updates, and project-specific regression tests under version control.