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.

Steps to migrate Keras 2 code to Keras 3:

  1. Upgrade Keras and the TensorFlow backend in an isolated Python environment.
    $ 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

  2. Select the TensorFlow backend before importing Keras.
    $ 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

  3. Change old TensorFlow Keras imports to standalone Keras imports.
    model.py
    # Keras 2
    from tensorflow import keras
    from tensorflow.keras import layers
     
    # Keras 3
    import keras
    from keras import layers
  4. Replace tf.keras namespace calls with keras calls in the migrated files.
    model.py
    # 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.

  5. Replace TensorFlow symbolic operations used during Functional model construction with Keras operations.
    model.py
    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.

  6. Disable JIT compilation only on models that hit TensorFlow XLA errors after the import migration.
    model.py
    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

  7. Save editable Keras artifacts with the native .keras format.
    model.py
    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

  8. Export a SavedModel only for serving or TensorFlow runtime handoff.
    model.py
    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

  9. Create a migration smoke-test script in the project root.
    migrate_keras3_smoke.py
    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.

  10. Run the smoke test with the selected backend.
    $ 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.

  11. Remove temporary smoke-test artifacts after the migrated project tests and serving checks pass.
    $ 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.