Freeze PyO3 wrappers & introduce interior mutability to avoid PyO3 borrow errors (#1253)

* Refactor schema, config, dataframe, and expression classes to use RwLock and Mutex for interior mutability

* Add error handling to CaseBuilder methods to preserve builder state

* Refactor to use parking_lot for interior mutability in schema, config, dataframe, and conditional expression modules

* Add concurrency tests for SqlSchema, Config, and DataFrame

* Add tests for CaseBuilder to ensure builder state is preserved on success

* Add test for independent handles in CaseBuilder to verify behavior

* Fix CaseBuilder to preserve state correctly in when() method

* Refactor to use named constant for boolean literals in test_expr.py

* fix ruff errors

* Refactor to introduce type aliases for cached batches in dataframe.rs

* Cherry pick from #1252

* Add most expr - cherry pick from #1252

* Add source root - cherry pick #1252

* Fix license comment formatting in config.rs

* Refactor caching logic to use a local variable for IPython environment check

* Add test for ensuring exposed pyclasses default to frozen

* Add PyO3 class mutability guidelines reference to contributor guide

* Mark boolean expression classes as frozen for immutability

* Refactor PyCaseBuilder methods to eliminate redundant take/store logic

* Refactor PyConfig methods to improve readability by encapsulating configuration reads

* Resolve patch apply conflicts for CaseBuilder concurrency improvements

- Added CaseBuilderHandle guard that keeps the underlying CaseBuilder alive while holding the mutex and restores it on drop
- Updated when, otherwise, and end methods to operate through the guard and consume the builder explicitly
- This prevents transient None states during concurrent access and improves thread safety

* Resolve Config optimization conflicts for improved read/write concurrency

- Released Config read guard before converting values to Python objects in get and get_all
- Ensures locks are held only while collecting scalar entries, not during expensive Python object conversion
- Added regression test that runs Config.get_all and Config.set concurrently to guard against read/write contention regressions
- Improves overall performance by reducing lock contention in multi-threaded scenarios

* Refactor PyConfig get methods for improved readability and performance

* Refactor test_expr.py to replace positional boolean literals with named constants for improved linting compliance

* fix ruff errors

* Add license header to test_pyclass_frozen.py for compliance

* Alternate approach to case expression

* Replace case builter with keeping the expressions and then applying as required

* Update unit tests

* Refactor case and when functions to utilize PyCaseBuilder for improved clarity and functionality

* Update src/expr/conditional_expr.rs

---------

Co-authored-by: ntjohnson1 <24689722+ntjohnson1@users.noreply.github.com>
Co-authored-by: Tim Saucer <timsaucer@gmail.com>
diff --git a/Cargo.lock b/Cargo.lock
index bf67256..2b62a69 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1594,6 +1594,7 @@
  "log",
  "mimalloc",
  "object_store",
+ "parking_lot",
  "prost",
  "prost-types",
  "pyo3",
diff --git a/Cargo.toml b/Cargo.toml
index f1d1a02..2c48bdd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -51,6 +51,7 @@
 object_store = { version = "0.12.3", features = ["aws", "gcp", "azure", "http"] }
 url = "2"
 log = "0.4.27"
+parking_lot = "0.12"
 
 [build-dependencies]
 prost-types = "0.13.1" # keep in line with `datafusion-substrait`
diff --git a/docs/source/contributor-guide/ffi.rst b/docs/source/contributor-guide/ffi.rst
index e201db7..e8a0398 100644
--- a/docs/source/contributor-guide/ffi.rst
+++ b/docs/source/contributor-guide/ffi.rst
@@ -137,6 +137,67 @@
     let my_provider = MyTableProvider::default();
     let ffi_provider = FFI_TableProvider::new(Arc::new(my_provider), false, None);
 
+.. _ffi_pyclass_mutability:
+
+PyO3 class mutability guidelines
+--------------------------------
+
+PyO3 bindings should present immutable wrappers whenever a struct stores shared or
+interior-mutable state. In practice this means that any ``#[pyclass]`` containing an
+``Arc<RwLock<_>>`` or similar synchronized primitive must opt into ``#[pyclass(frozen)]``
+unless there is a compelling reason not to.
+
+The :mod:`datafusion` configuration helpers illustrate the preferred pattern. The
+``PyConfig`` class in :file:`src/config.rs` stores an ``Arc<RwLock<ConfigOptions>>`` and is
+explicitly frozen so callers interact with configuration state through provided methods
+instead of mutating the container directly:
+
+.. code-block:: rust
+
+    #[pyclass(name = "Config", module = "datafusion", subclass, frozen)]
+    #[derive(Clone)]
+    pub(crate) struct PyConfig {
+        config: Arc<RwLock<ConfigOptions>>,
+    }
+
+The same approach applies to execution contexts. ``PySessionContext`` in
+:file:`src/context.rs` stays frozen even though it shares mutable state internally via
+``SessionContext``. This ensures PyO3 tracks borrows correctly while Python-facing APIs
+clone the inner ``SessionContext`` or return new wrappers instead of mutating the
+existing instance in place:
+
+.. code-block:: rust
+
+    #[pyclass(frozen, name = "SessionContext", module = "datafusion", subclass)]
+    #[derive(Clone)]
+    pub struct PySessionContext {
+        pub ctx: SessionContext,
+    }
+
+Occasionally a type must remain mutable—for example when PyO3 attribute setters need to
+update fields directly. In these rare cases add an inline justification so reviewers and
+future contributors understand why ``frozen`` is unsafe to enable. ``DataTypeMap`` in
+:file:`src/common/data_type.rs` includes such a comment because PyO3 still needs to track
+field updates:
+
+.. code-block:: rust
+
+    // TODO: This looks like this needs pyo3 tracking so leaving unfrozen for now
+    #[derive(Debug, Clone)]
+    #[pyclass(name = "DataTypeMap", module = "datafusion.common", subclass)]
+    pub struct DataTypeMap {
+        #[pyo3(get, set)]
+        pub arrow_type: PyDataType,
+        #[pyo3(get, set)]
+        pub python_type: PythonType,
+        #[pyo3(get, set)]
+        pub sql_type: SqlType,
+    }
+
+When reviewers encounter a mutable ``#[pyclass]`` without a comment, they should request
+an explanation or ask that ``frozen`` be added. Keeping these wrappers frozen by default
+helps avoid subtle bugs stemming from PyO3's interior mutability tracking.
+
 If you were interfacing with a library that provided the above ``FFI_TableProvider`` and
 you needed to turn it back into an ``TableProvider``, you can turn it into a
 ``ForeignTableProvider`` with implements the ``TableProvider`` trait.
diff --git a/docs/source/contributor-guide/introduction.rst b/docs/source/contributor-guide/introduction.rst
index 6cb05c6..33c2b27 100644
--- a/docs/source/contributor-guide/introduction.rst
+++ b/docs/source/contributor-guide/introduction.rst
@@ -26,6 +26,10 @@
 In addition to submitting new PRs, we have a healthy tradition of community members reviewing each other’s PRs.
 Doing so is a great way to help the community as well as get more familiar with Rust and the relevant codebases.
 
+Before opening a pull request that touches PyO3 bindings, please review the
+:ref:`PyO3 class mutability guidelines <ffi_pyclass_mutability>` so you can flag missing
+``#[pyclass(frozen)]`` annotations during development and review.
+
 How to develop
 --------------
 
diff --git a/pyproject.toml b/pyproject.toml
index edecc45..69d31ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -107,6 +107,9 @@
 [tool.ruff.lint.pycodestyle]
 max-doc-length = 88
 
+[tool.ruff.lint.flake8-boolean-trap]
+extend-allowed-calls = ["lit", "datafusion.lit"]
+
 # Disable docstring checking for these directories
 [tool.ruff.lint.per-file-ignores]
 "python/tests/*" = [
diff --git a/python/tests/test_concurrency.py b/python/tests/test_concurrency.py
new file mode 100644
index 0000000..f790f94
--- /dev/null
+++ b/python/tests/test_concurrency.py
@@ -0,0 +1,126 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from concurrent.futures import ThreadPoolExecutor
+
+import pyarrow as pa
+from datafusion import Config, SessionContext, col, lit
+from datafusion import functions as f
+from datafusion.common import SqlSchema
+
+
+def _run_in_threads(fn, count: int = 8) -> None:
+    with ThreadPoolExecutor(max_workers=count) as executor:
+        futures = [executor.submit(fn, i) for i in range(count)]
+        for future in futures:
+            # Propagate any exception raised in the worker thread.
+            future.result()
+
+
+def test_concurrent_access_to_shared_structures() -> None:
+    """Exercise SqlSchema, Config, and DataFrame concurrently."""
+
+    schema = SqlSchema("concurrency")
+    config = Config()
+    ctx = SessionContext()
+
+    batch = pa.record_batch([pa.array([1, 2, 3], type=pa.int32())], names=["value"])
+    df = ctx.create_dataframe([[batch]])
+
+    config_key = "datafusion.execution.batch_size"
+    expected_rows = batch.num_rows
+
+    def worker(index: int) -> None:
+        schema.name = f"concurrency-{index}"
+        assert schema.name.startswith("concurrency-")
+        # Exercise getters that use internal locks.
+        assert isinstance(schema.tables, list)
+        assert isinstance(schema.views, list)
+        assert isinstance(schema.functions, list)
+
+        config.set(config_key, str(1024 + index))
+        assert config.get(config_key) is not None
+        # Access the full config map to stress lock usage.
+        assert config_key in config.get_all()
+
+        batches = df.collect()
+        assert sum(batch.num_rows for batch in batches) == expected_rows
+
+    _run_in_threads(worker, count=12)
+
+
+def test_config_set_during_get_all() -> None:
+    """Ensure config writes proceed while another thread reads all entries."""
+
+    config = Config()
+    key = "datafusion.execution.batch_size"
+
+    def reader() -> None:
+        for _ in range(200):
+            # get_all should not hold the lock while converting to Python objects
+            config.get_all()
+
+    def writer() -> None:
+        for index in range(200):
+            config.set(key, str(1024 + index))
+
+    with ThreadPoolExecutor(max_workers=2) as executor:
+        reader_future = executor.submit(reader)
+        writer_future = executor.submit(writer)
+        reader_future.result(timeout=10)
+        writer_future.result(timeout=10)
+
+    assert config.get(key) is not None
+
+
+def test_case_builder_reuse_from_multiple_threads() -> None:
+    """Ensure the case builder can be safely reused across threads."""
+
+    ctx = SessionContext()
+    values = pa.array([0, 1, 2, 3, 4], type=pa.int32())
+    df = ctx.create_dataframe([[pa.record_batch([values], names=["value"])]])
+
+    base_builder = f.case(col("value"))
+
+    def add_case(i: int) -> None:
+        nonlocal base_builder
+        base_builder = base_builder.when(lit(i), lit(f"value-{i}"))
+
+    _run_in_threads(add_case, count=8)
+
+    with ThreadPoolExecutor(max_workers=2) as executor:
+        otherwise_future = executor.submit(base_builder.otherwise, lit("default"))
+        case_expr = otherwise_future.result()
+
+    result = df.select(case_expr.alias("label")).collect()
+    assert sum(batch.num_rows for batch in result) == len(values)
+
+    predicate_builder = f.when(col("value") == lit(0), lit("zero"))
+
+    def add_predicate(i: int) -> None:
+        predicate_builder.when(col("value") == lit(i + 1), lit(f"value-{i + 1}"))
+
+    _run_in_threads(add_predicate, count=4)
+
+    with ThreadPoolExecutor(max_workers=2) as executor:
+        end_future = executor.submit(predicate_builder.end)
+        predicate_expr = end_future.result()
+
+    result = df.select(predicate_expr.alias("label")).collect()
+    assert sum(batch.num_rows for batch in result) == len(values)
diff --git a/python/tests/test_expr.py b/python/tests/test_expr.py
index 810d419..7847826 100644
--- a/python/tests/test_expr.py
+++ b/python/tests/test_expr.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import re
+from concurrent.futures import ThreadPoolExecutor
 from datetime import datetime, timezone
 
 import pyarrow as pa
@@ -200,6 +201,98 @@
     assert not variant.negated()
 
 
+def test_case_builder_error_preserves_builder_state():
+    case_builder = functions.when(lit(True), lit(1))
+
+    with pytest.raises(Exception) as exc_info:
+        _ = case_builder.otherwise(lit("bad"))
+
+    err_msg = str(exc_info.value)
+    assert "multiple data types" in err_msg
+    assert "CaseBuilder has already been consumed" not in err_msg
+
+    _ = case_builder.end()
+
+    err_msg = str(exc_info.value)
+    assert "multiple data types" in err_msg
+    assert "CaseBuilder has already been consumed" not in err_msg
+
+
+def test_case_builder_success_preserves_builder_state():
+    ctx = SessionContext()
+    df = ctx.from_pydict({"flag": [False]}, name="tbl")
+
+    case_builder = functions.when(col("flag"), lit("true"))
+
+    expr_default_one = case_builder.otherwise(lit("default-1")).alias("result")
+    result_one = df.select(expr_default_one).collect()
+    assert result_one[0].column(0).to_pylist() == ["default-1"]
+
+    expr_default_two = case_builder.otherwise(lit("default-2")).alias("result")
+    result_two = df.select(expr_default_two).collect()
+    assert result_two[0].column(0).to_pylist() == ["default-2"]
+
+    expr_end_one = case_builder.end().alias("result")
+    end_one = df.select(expr_end_one).collect()
+    assert end_one[0].column(0).to_pylist() == [None]
+
+
+def test_case_builder_when_handles_are_independent():
+    ctx = SessionContext()
+    df = ctx.from_pydict(
+        {
+            "flag": [True, False, False, False],
+            "value": [1, 15, 25, 5],
+        },
+        name="tbl",
+    )
+
+    base_builder = functions.when(col("flag"), lit("flag-true"))
+
+    first_builder = base_builder.when(col("value") > lit(10), lit("gt10"))
+    second_builder = base_builder.when(col("value") > lit(20), lit("gt20"))
+
+    first_builder = first_builder.when(lit(True), lit("final-one"))
+
+    expr_first = first_builder.otherwise(lit("fallback-one")).alias("first")
+    expr_second = second_builder.otherwise(lit("fallback-two")).alias("second")
+
+    result = df.select(expr_first, expr_second).collect()[0]
+
+    assert result.column(0).to_pylist() == [
+        "flag-true",
+        "gt10",
+        "gt10",
+        "final-one",
+    ]
+    assert result.column(1).to_pylist() == [
+        "flag-true",
+        "fallback-two",
+        "gt20",
+        "fallback-two",
+    ]
+
+
+def test_case_builder_when_thread_safe():
+    case_builder = functions.when(lit(True), lit(1))
+
+    def build_expr(value: int) -> bool:
+        builder = case_builder.when(lit(True), lit(value))
+        builder.otherwise(lit(value))
+        return True
+
+    with ThreadPoolExecutor(max_workers=8) as executor:
+        futures = [executor.submit(build_expr, idx) for idx in range(16)]
+        results = [future.result() for future in futures]
+
+    assert all(results)
+
+    # Ensure the shared builder remains usable after concurrent `when` calls.
+    follow_up_builder = case_builder.when(lit(True), lit(42))
+    assert isinstance(follow_up_builder, type(case_builder))
+    follow_up_builder.otherwise(lit(7))
+
+
 def test_expr_getitem() -> None:
     ctx = SessionContext()
     data = {
diff --git a/python/tests/test_pyclass_frozen.py b/python/tests/test_pyclass_frozen.py
new file mode 100644
index 0000000..189ea8d
--- /dev/null
+++ b/python/tests/test_pyclass_frozen.py
@@ -0,0 +1,104 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Ensure exposed pyclasses default to frozen."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterator
+
+PYCLASS_RE = re.compile(
+    r"#\[\s*pyclass\s*(?:\((?P<args>.*?)\))?\s*\]",
+    re.DOTALL,
+)
+ARG_STRING_RE = re.compile(
+    r"(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*\"(?P<value>[^\"]+)\"",
+)
+STRUCT_NAME_RE = re.compile(
+    r"\b(?:pub\s+)?(?:struct|enum)\s+"
+    r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)",
+)
+
+
+@dataclass
+class PyClass:
+    module: str
+    name: str
+    frozen: bool
+    source: Path
+
+
+def iter_pyclasses(root: Path) -> Iterator[PyClass]:
+    for path in root.rglob("*.rs"):
+        text = path.read_text(encoding="utf8")
+        for match in PYCLASS_RE.finditer(text):
+            args = match.group("args") or ""
+            frozen = re.search(r"\bfrozen\b", args) is not None
+
+            module = None
+            name = None
+            for arg_match in ARG_STRING_RE.finditer(args):
+                key = arg_match.group("key")
+                value = arg_match.group("value")
+                if key == "module":
+                    module = value
+                elif key == "name":
+                    name = value
+
+            remainder = text[match.end() :]
+            struct_match = STRUCT_NAME_RE.search(remainder)
+            struct_name = struct_match.group("name") if struct_match else None
+
+            yield PyClass(
+                module=module or "datafusion",
+                name=name or struct_name or "<unknown>",
+                frozen=frozen,
+                source=path,
+            )
+
+
+def test_pyclasses_are_frozen() -> None:
+    allowlist = {
+        # NOTE: Any new exceptions must include a justification comment
+        # in the Rust source and, ideally, a follow-up issue to remove
+        # the exemption.
+        ("datafusion.common", "SqlTable"),
+        ("datafusion.common", "SqlView"),
+        ("datafusion.common", "DataTypeMap"),
+        ("datafusion.expr", "TryCast"),
+        ("datafusion.expr", "WriteOp"),
+    }
+
+    unfrozen = [
+        pyclass
+        for pyclass in iter_pyclasses(Path("src"))
+        if not pyclass.frozen and (pyclass.module, pyclass.name) not in allowlist
+    ]
+
+    if unfrozen:
+        msg = (
+            "Found pyclasses missing `frozen`; add them to the allowlist only "
+            "with a justification comment and follow-up plan:\n"
+        )
+        msg += "\n".join(
+            (f"- {pyclass.module}.{pyclass.name} (defined in {pyclass.source})")
+            for pyclass in unfrozen
+        )
+        assert not unfrozen, msg
diff --git a/src/catalog.rs b/src/catalog.rs
index 17d4ec3..b5fa3da 100644
--- a/src/catalog.rs
+++ b/src/catalog.rs
@@ -36,19 +36,19 @@
 use std::collections::HashSet;
 use std::sync::Arc;
 
-#[pyclass(name = "RawCatalog", module = "datafusion.catalog", subclass)]
+#[pyclass(frozen, name = "RawCatalog", module = "datafusion.catalog", subclass)]
 #[derive(Clone)]
 pub struct PyCatalog {
     pub catalog: Arc<dyn CatalogProvider>,
 }
 
-#[pyclass(name = "RawSchema", module = "datafusion.catalog", subclass)]
+#[pyclass(frozen, name = "RawSchema", module = "datafusion.catalog", subclass)]
 #[derive(Clone)]
 pub struct PySchema {
     pub schema: Arc<dyn SchemaProvider>,
 }
 
-#[pyclass(name = "RawTable", module = "datafusion.catalog", subclass)]
+#[pyclass(frozen, name = "RawTable", module = "datafusion.catalog", subclass)]
 #[derive(Clone)]
 pub struct PyTable {
     pub table: Arc<dyn TableProvider>,
diff --git a/src/common/data_type.rs b/src/common/data_type.rs
index 4d77433..3cbe313 100644
--- a/src/common/data_type.rs
+++ b/src/common/data_type.rs
@@ -37,7 +37,7 @@
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
-#[pyclass(eq, eq_int, name = "RexType", module = "datafusion.common")]
+#[pyclass(frozen, eq, eq_int, name = "RexType", module = "datafusion.common")]
 pub enum RexType {
     Alias,
     Literal,
@@ -56,6 +56,7 @@
 /// and manageable location. Therefore this structure exists
 /// to map those types and provide a simple place for developers
 /// to map types from one system to another.
+// TODO: This looks like this needs pyo3 tracking so leaving unfrozen for now
 #[derive(Debug, Clone)]
 #[pyclass(name = "DataTypeMap", module = "datafusion.common", subclass)]
 pub struct DataTypeMap {
@@ -577,7 +578,7 @@
 /// Since `DataType` exists in another package we cannot make that happen here so we wrap
 /// `DataType` as `PyDataType` This exists solely to satisfy those constraints.
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
-#[pyclass(name = "DataType", module = "datafusion.common")]
+#[pyclass(frozen, name = "DataType", module = "datafusion.common")]
 pub struct PyDataType {
     pub data_type: DataType,
 }
@@ -635,7 +636,7 @@
 
 /// Represents the possible Python types that can be mapped to the SQL types
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
-#[pyclass(eq, eq_int, name = "PythonType", module = "datafusion.common")]
+#[pyclass(frozen, eq, eq_int, name = "PythonType", module = "datafusion.common")]
 pub enum PythonType {
     Array,
     Bool,
@@ -655,7 +656,7 @@
 #[allow(non_camel_case_types)]
 #[allow(clippy::upper_case_acronyms)]
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
-#[pyclass(eq, eq_int, name = "SqlType", module = "datafusion.common")]
+#[pyclass(frozen, eq, eq_int, name = "SqlType", module = "datafusion.common")]
 pub enum SqlType {
     ANY,
     ARRAY,
@@ -713,7 +714,13 @@
 #[allow(non_camel_case_types)]
 #[allow(clippy::upper_case_acronyms)]
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
-#[pyclass(eq, eq_int, name = "NullTreatment", module = "datafusion.common")]
+#[pyclass(
+    frozen,
+    eq,
+    eq_int,
+    name = "NullTreatment",
+    module = "datafusion.common"
+)]
 pub enum NullTreatment {
     IGNORE_NULLS,
     RESPECT_NULLS,
diff --git a/src/common/df_schema.rs b/src/common/df_schema.rs
index 4e1d840..eb62469 100644
--- a/src/common/df_schema.rs
+++ b/src/common/df_schema.rs
@@ -21,7 +21,7 @@
 use pyo3::prelude::*;
 
 #[derive(Debug, Clone)]
-#[pyclass(name = "DFSchema", module = "datafusion.common", subclass)]
+#[pyclass(frozen, name = "DFSchema", module = "datafusion.common", subclass)]
 pub struct PyDFSchema {
     schema: Arc<DFSchema>,
 }
diff --git a/src/common/function.rs b/src/common/function.rs
index a8d752f..bc6f231 100644
--- a/src/common/function.rs
+++ b/src/common/function.rs
@@ -22,7 +22,7 @@
 
 use super::data_type::PyDataType;
 
-#[pyclass(name = "SqlFunction", module = "datafusion.common", subclass)]
+#[pyclass(frozen, name = "SqlFunction", module = "datafusion.common", subclass)]
 #[derive(Debug, Clone)]
 pub struct SqlFunction {
     pub name: String,
diff --git a/src/common/schema.rs b/src/common/schema.rs
index 752c39b..14ab630 100644
--- a/src/common/schema.rs
+++ b/src/common/schema.rs
@@ -33,17 +33,15 @@
 
 use super::{data_type::DataTypeMap, function::SqlFunction};
 
-#[pyclass(name = "SqlSchema", module = "datafusion.common", subclass)]
+use parking_lot::RwLock;
+
+#[pyclass(name = "SqlSchema", module = "datafusion.common", subclass, frozen)]
 #[derive(Debug, Clone)]
 pub struct SqlSchema {
-    #[pyo3(get, set)]
-    pub name: String,
-    #[pyo3(get, set)]
-    pub tables: Vec<SqlTable>,
-    #[pyo3(get, set)]
-    pub views: Vec<SqlView>,
-    #[pyo3(get, set)]
-    pub functions: Vec<SqlFunction>,
+    name: Arc<RwLock<String>>,
+    tables: Arc<RwLock<Vec<SqlTable>>>,
+    views: Arc<RwLock<Vec<SqlView>>>,
+    functions: Arc<RwLock<Vec<SqlFunction>>>,
 }
 
 #[pyclass(name = "SqlTable", module = "datafusion.common", subclass)]
@@ -104,28 +102,70 @@
     #[new]
     pub fn new(schema_name: &str) -> Self {
         Self {
-            name: schema_name.to_owned(),
-            tables: Vec::new(),
-            views: Vec::new(),
-            functions: Vec::new(),
+            name: Arc::new(RwLock::new(schema_name.to_owned())),
+            tables: Arc::new(RwLock::new(Vec::new())),
+            views: Arc::new(RwLock::new(Vec::new())),
+            functions: Arc::new(RwLock::new(Vec::new())),
         }
     }
 
+    #[getter]
+    fn name(&self) -> PyResult<String> {
+        Ok(self.name.read().clone())
+    }
+
+    #[setter]
+    fn set_name(&self, value: String) -> PyResult<()> {
+        *self.name.write() = value;
+        Ok(())
+    }
+
+    #[getter]
+    fn tables(&self) -> PyResult<Vec<SqlTable>> {
+        Ok(self.tables.read().clone())
+    }
+
+    #[setter]
+    fn set_tables(&self, tables: Vec<SqlTable>) -> PyResult<()> {
+        *self.tables.write() = tables;
+        Ok(())
+    }
+
+    #[getter]
+    fn views(&self) -> PyResult<Vec<SqlView>> {
+        Ok(self.views.read().clone())
+    }
+
+    #[setter]
+    fn set_views(&self, views: Vec<SqlView>) -> PyResult<()> {
+        *self.views.write() = views;
+        Ok(())
+    }
+
+    #[getter]
+    fn functions(&self) -> PyResult<Vec<SqlFunction>> {
+        Ok(self.functions.read().clone())
+    }
+
+    #[setter]
+    fn set_functions(&self, functions: Vec<SqlFunction>) -> PyResult<()> {
+        *self.functions.write() = functions;
+        Ok(())
+    }
+
     pub fn table_by_name(&self, table_name: &str) -> Option<SqlTable> {
-        for tbl in &self.tables {
-            if tbl.name.eq(table_name) {
-                return Some(tbl.clone());
-            }
-        }
-        None
+        let tables = self.tables.read();
+        tables.iter().find(|tbl| tbl.name.eq(table_name)).cloned()
     }
 
-    pub fn add_table(&mut self, table: SqlTable) {
-        self.tables.push(table);
+    pub fn add_table(&self, table: SqlTable) {
+        let mut tables = self.tables.write();
+        tables.push(table);
     }
 
-    pub fn drop_table(&mut self, table_name: String) {
-        self.tables.retain(|x| !x.name.eq(&table_name));
+    pub fn drop_table(&self, table_name: String) {
+        let mut tables = self.tables.write();
+        tables.retain(|x| !x.name.eq(&table_name));
     }
 }
 
@@ -208,7 +248,7 @@
     true
 }
 
-#[pyclass(name = "SqlStatistics", module = "datafusion.common", subclass)]
+#[pyclass(frozen, name = "SqlStatistics", module = "datafusion.common", subclass)]
 #[derive(Debug, Clone)]
 pub struct SqlStatistics {
     row_count: f64,
@@ -227,7 +267,7 @@
     }
 }
 
-#[pyclass(name = "Constraints", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Constraints", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyConstraints {
     pub constraints: Constraints,
@@ -252,7 +292,7 @@
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
-#[pyclass(eq, eq_int, name = "TableType", module = "datafusion.common")]
+#[pyclass(frozen, eq, eq_int, name = "TableType", module = "datafusion.common")]
 pub enum PyTableType {
     Base,
     View,
@@ -279,7 +319,7 @@
     }
 }
 
-#[pyclass(name = "TableSource", module = "datafusion.common", subclass)]
+#[pyclass(frozen, name = "TableSource", module = "datafusion.common", subclass)]
 #[derive(Clone)]
 pub struct PyTableSource {
     pub table_source: Arc<dyn TableSource>,
diff --git a/src/config.rs b/src/config.rs
index 20f2219..1726e5d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -15,6 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::sync::Arc;
+
 use pyo3::prelude::*;
 use pyo3::types::*;
 
@@ -22,11 +24,11 @@
 
 use crate::errors::PyDataFusionResult;
 use crate::utils::py_obj_to_scalar_value;
-
-#[pyclass(name = "Config", module = "datafusion", subclass)]
+use parking_lot::RwLock;
+#[pyclass(name = "Config", module = "datafusion", subclass, frozen)]
 #[derive(Clone)]
 pub(crate) struct PyConfig {
-    config: ConfigOptions,
+    config: Arc<RwLock<ConfigOptions>>,
 }
 
 #[pymethods]
@@ -34,7 +36,7 @@
     #[new]
     fn py_new() -> Self {
         Self {
-            config: ConfigOptions::new(),
+            config: Arc::new(RwLock::new(ConfigOptions::new())),
         }
     }
 
@@ -42,41 +44,54 @@
     #[staticmethod]
     pub fn from_env() -> PyDataFusionResult<Self> {
         Ok(Self {
-            config: ConfigOptions::from_env()?,
+            config: Arc::new(RwLock::new(ConfigOptions::from_env()?)),
         })
     }
 
     /// Get a configuration option
-    pub fn get<'py>(&mut self, key: &str, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
-        let options = self.config.to_owned();
-        for entry in options.entries() {
-            if entry.key == key {
-                return Ok(entry.value.into_pyobject(py)?);
-            }
+    pub fn get<'py>(&self, key: &str, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
+        let value: Option<Option<String>> = {
+            let options = self.config.read();
+            options
+                .entries()
+                .into_iter()
+                .find_map(|entry| (entry.key == key).then_some(entry.value.clone()))
+        };
+
+        match value {
+            Some(value) => Ok(value.into_pyobject(py)?),
+            None => Ok(None::<String>.into_pyobject(py)?),
         }
-        Ok(None::<String>.into_pyobject(py)?)
     }
 
     /// Set a configuration option
-    pub fn set(&mut self, key: &str, value: PyObject, py: Python) -> PyDataFusionResult<()> {
+    pub fn set(&self, key: &str, value: PyObject, py: Python) -> PyDataFusionResult<()> {
         let scalar_value = py_obj_to_scalar_value(py, value)?;
-        self.config.set(key, scalar_value.to_string().as_str())?;
+        let mut options = self.config.write();
+        options.set(key, scalar_value.to_string().as_str())?;
         Ok(())
     }
 
     /// Get all configuration options
-    pub fn get_all(&mut self, py: Python) -> PyResult<PyObject> {
+    pub fn get_all(&self, py: Python) -> PyResult<PyObject> {
+        let entries: Vec<(String, Option<String>)> = {
+            let options = self.config.read();
+            options
+                .entries()
+                .into_iter()
+                .map(|entry| (entry.key.clone(), entry.value.clone()))
+                .collect()
+        };
+
         let dict = PyDict::new(py);
-        let options = self.config.to_owned();
-        for entry in options.entries() {
-            dict.set_item(entry.key, entry.value.clone().into_pyobject(py)?)?;
+        for (key, value) in entries {
+            dict.set_item(key, value.into_pyobject(py)?)?;
         }
         Ok(dict.into())
     }
 
-    fn __repr__(&mut self, py: Python) -> PyResult<String> {
-        let dict = self.get_all(py);
-        match dict {
+    fn __repr__(&self, py: Python) -> PyResult<String> {
+        match self.get_all(py) {
             Ok(result) => Ok(format!("Config({result})")),
             Err(err) => Ok(format!("Error: {:?}", err.to_string())),
         }
diff --git a/src/context.rs b/src/context.rs
index 0ccb032..e3f978e 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -77,7 +77,7 @@
 use tokio::task::JoinHandle;
 
 /// Configuration options for a SessionContext
-#[pyclass(name = "SessionConfig", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "SessionConfig", module = "datafusion", subclass)]
 #[derive(Clone, Default)]
 pub struct PySessionConfig {
     pub config: SessionConfig,
@@ -170,7 +170,7 @@
 }
 
 /// Runtime options for a SessionContext
-#[pyclass(name = "RuntimeEnvBuilder", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "RuntimeEnvBuilder", module = "datafusion", subclass)]
 #[derive(Clone)]
 pub struct PyRuntimeEnvBuilder {
     pub builder: RuntimeEnvBuilder,
@@ -257,7 +257,7 @@
 }
 
 /// `PySQLOptions` allows you to specify options to the sql execution.
-#[pyclass(name = "SQLOptions", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "SQLOptions", module = "datafusion", subclass)]
 #[derive(Clone)]
 pub struct PySQLOptions {
     pub options: SQLOptions,
diff --git a/src/dataframe.rs b/src/dataframe.rs
index 5882acf..555a850 100644
--- a/src/dataframe.rs
+++ b/src/dataframe.rs
@@ -58,10 +58,17 @@
     expr::{sort_expr::PySortExpr, PyExpr},
 };
 
+use parking_lot::Mutex;
+
+// Type aliases to simplify very complex types used in this file and
+// avoid compiler complaints about deeply nested types in struct fields.
+type CachedBatches = Option<(Vec<RecordBatch>, bool)>;
+type SharedCachedBatches = Arc<Mutex<CachedBatches>>;
+
 // https://github.com/apache/datafusion-python/pull/1016#discussion_r1983239116
 // - we have not decided on the table_provider approach yet
 // this is an interim implementation
-#[pyclass(name = "TableProvider", module = "datafusion")]
+#[pyclass(frozen, name = "TableProvider", module = "datafusion")]
 pub struct PyTableProvider {
     provider: Arc<dyn TableProvider + Send>,
 }
@@ -188,7 +195,7 @@
 }
 
 /// Python mapping of `ParquetOptions` (includes just the writer-related options).
-#[pyclass(name = "ParquetWriterOptions", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "ParquetWriterOptions", module = "datafusion", subclass)]
 #[derive(Clone, Default)]
 pub struct PyParquetWriterOptions {
     options: ParquetOptions,
@@ -249,7 +256,7 @@
 }
 
 /// Python mapping of `ParquetColumnOptions`.
-#[pyclass(name = "ParquetColumnOptions", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "ParquetColumnOptions", module = "datafusion", subclass)]
 #[derive(Clone, Default)]
 pub struct PyParquetColumnOptions {
     options: ParquetColumnOptions,
@@ -284,13 +291,13 @@
 /// A PyDataFrame is a representation of a logical plan and an API to compose statements.
 /// Use it to build a plan and `.collect()` to execute the plan and collect the result.
 /// The actual execution of a plan runs natively on Rust and Arrow on a multi-threaded environment.
-#[pyclass(name = "DataFrame", module = "datafusion", subclass)]
+#[pyclass(name = "DataFrame", module = "datafusion", subclass, frozen)]
 #[derive(Clone)]
 pub struct PyDataFrame {
     df: Arc<DataFrame>,
 
     // In IPython environment cache batches between __repr__ and _repr_html_ calls.
-    batches: Option<(Vec<RecordBatch>, bool)>,
+    batches: SharedCachedBatches,
 }
 
 impl PyDataFrame {
@@ -298,16 +305,24 @@
     pub fn new(df: DataFrame) -> Self {
         Self {
             df: Arc::new(df),
-            batches: None,
+            batches: Arc::new(Mutex::new(None)),
         }
     }
 
-    fn prepare_repr_string(&mut self, py: Python, as_html: bool) -> PyDataFusionResult<String> {
+    fn prepare_repr_string(&self, py: Python, as_html: bool) -> PyDataFusionResult<String> {
         // Get the Python formatter and config
         let PythonFormatter { formatter, config } = get_python_formatter_with_config(py)?;
 
-        let should_cache = *is_ipython_env(py) && self.batches.is_none();
-        let (batches, has_more) = match self.batches.take() {
+        let is_ipython = *is_ipython_env(py);
+
+        let (cached_batches, should_cache) = {
+            let mut cache = self.batches.lock();
+            let should_cache = is_ipython && cache.is_none();
+            let batches = cache.take();
+            (batches, should_cache)
+        };
+
+        let (batches, has_more) = match cached_batches {
             Some(b) => b,
             None => wait_for_future(
                 py,
@@ -346,7 +361,8 @@
         let html_str: String = html_result.extract()?;
 
         if should_cache {
-            self.batches = Some((batches, has_more));
+            let mut cache = self.batches.lock();
+            *cache = Some((batches.clone(), has_more));
         }
 
         Ok(html_str)
@@ -376,7 +392,7 @@
         }
     }
 
-    fn __repr__(&mut self, py: Python) -> PyDataFusionResult<String> {
+    fn __repr__(&self, py: Python) -> PyDataFusionResult<String> {
         self.prepare_repr_string(py, false)
     }
 
@@ -411,7 +427,7 @@
         Ok(format!("DataFrame()\n{batches_as_displ}{additional_str}"))
     }
 
-    fn _repr_html_(&mut self, py: Python) -> PyDataFusionResult<String> {
+    fn _repr_html_(&self, py: Python) -> PyDataFusionResult<String> {
         self.prepare_repr_string(py, true)
     }
 
@@ -874,7 +890,7 @@
 
     #[pyo3(signature = (requested_schema=None))]
     fn __arrow_c_stream__<'py>(
-        &'py mut self,
+        &'py self,
         py: Python<'py>,
         requested_schema: Option<Bound<'py, PyCapsule>>,
     ) -> PyDataFusionResult<Bound<'py, PyCapsule>> {
diff --git a/src/expr.rs b/src/expr.rs
index e2c5302..c9eddaa 100644
--- a/src/expr.rs
+++ b/src/expr.rs
@@ -115,7 +115,7 @@
 use sort_expr::{to_sort_expressions, PySortExpr};
 
 /// A PyExpr that can be used on a DataFrame
-#[pyclass(name = "RawExpr", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "RawExpr", module = "datafusion.expr", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyExpr {
     pub expr: Expr,
@@ -637,7 +637,7 @@
     }
 }
 
-#[pyclass(name = "ExprFuncBuilder", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "ExprFuncBuilder", module = "datafusion.expr", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyExprFuncBuilder {
     pub builder: ExprFuncBuilder,
diff --git a/src/expr/aggregate.rs b/src/expr/aggregate.rs
index fd43932..4af7c75 100644
--- a/src/expr/aggregate.rs
+++ b/src/expr/aggregate.rs
@@ -28,7 +28,7 @@
 use crate::expr::PyExpr;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "Aggregate", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Aggregate", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyAggregate {
     aggregate: Aggregate,
diff --git a/src/expr/aggregate_expr.rs b/src/expr/aggregate_expr.rs
index 7c5d3d3..72ba063 100644
--- a/src/expr/aggregate_expr.rs
+++ b/src/expr/aggregate_expr.rs
@@ -20,7 +20,12 @@
 use pyo3::prelude::*;
 use std::fmt::{Display, Formatter};
 
-#[pyclass(name = "AggregateFunction", module = "datafusion.expr", subclass)]
+#[pyclass(
+    frozen,
+    name = "AggregateFunction",
+    module = "datafusion.expr",
+    subclass
+)]
 #[derive(Clone)]
 pub struct PyAggregateFunction {
     aggr: AggregateFunction,
diff --git a/src/expr/alias.rs b/src/expr/alias.rs
index 40746f2..588c00f 100644
--- a/src/expr/alias.rs
+++ b/src/expr/alias.rs
@@ -21,7 +21,7 @@
 
 use datafusion::logical_expr::expr::Alias;
 
-#[pyclass(name = "Alias", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Alias", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyAlias {
     alias: Alias,
diff --git a/src/expr/analyze.rs b/src/expr/analyze.rs
index e8081e9..c7caeeb 100644
--- a/src/expr/analyze.rs
+++ b/src/expr/analyze.rs
@@ -23,7 +23,7 @@
 use crate::common::df_schema::PyDFSchema;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "Analyze", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Analyze", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyAnalyze {
     analyze: Analyze,
diff --git a/src/expr/between.rs b/src/expr/between.rs
index 817f1ba..1f61599 100644
--- a/src/expr/between.rs
+++ b/src/expr/between.rs
@@ -20,7 +20,7 @@
 use pyo3::prelude::*;
 use std::fmt::{self, Display, Formatter};
 
-#[pyclass(name = "Between", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Between", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyBetween {
     between: Between,
diff --git a/src/expr/binary_expr.rs b/src/expr/binary_expr.rs
index 7402992..9437958 100644
--- a/src/expr/binary_expr.rs
+++ b/src/expr/binary_expr.rs
@@ -19,7 +19,7 @@
 use datafusion::logical_expr::BinaryExpr;
 use pyo3::prelude::*;
 
-#[pyclass(name = "BinaryExpr", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "BinaryExpr", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyBinaryExpr {
     expr: BinaryExpr,
diff --git a/src/expr/bool_expr.rs b/src/expr/bool_expr.rs
index e67e25d..0d2b051 100644
--- a/src/expr/bool_expr.rs
+++ b/src/expr/bool_expr.rs
@@ -21,7 +21,7 @@
 
 use super::PyExpr;
 
-#[pyclass(name = "Not", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Not", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyNot {
     expr: Expr,
@@ -51,7 +51,7 @@
     }
 }
 
-#[pyclass(name = "IsNotNull", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "IsNotNull", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyIsNotNull {
     expr: Expr,
@@ -81,7 +81,7 @@
     }
 }
 
-#[pyclass(name = "IsNull", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "IsNull", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyIsNull {
     expr: Expr,
@@ -111,7 +111,7 @@
     }
 }
 
-#[pyclass(name = "IsTrue", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "IsTrue", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyIsTrue {
     expr: Expr,
@@ -141,7 +141,7 @@
     }
 }
 
-#[pyclass(name = "IsFalse", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "IsFalse", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyIsFalse {
     expr: Expr,
@@ -171,7 +171,7 @@
     }
 }
 
-#[pyclass(name = "IsUnknown", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "IsUnknown", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyIsUnknown {
     expr: Expr,
@@ -201,7 +201,7 @@
     }
 }
 
-#[pyclass(name = "IsNotTrue", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "IsNotTrue", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyIsNotTrue {
     expr: Expr,
@@ -231,7 +231,7 @@
     }
 }
 
-#[pyclass(name = "IsNotFalse", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "IsNotFalse", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyIsNotFalse {
     expr: Expr,
@@ -261,7 +261,7 @@
     }
 }
 
-#[pyclass(name = "IsNotUnknown", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "IsNotUnknown", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyIsNotUnknown {
     expr: Expr,
@@ -291,7 +291,7 @@
     }
 }
 
-#[pyclass(name = "Negative", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Negative", module = "datafusion.expr", subclass)]
 #[derive(Clone, Debug)]
 pub struct PyNegative {
     expr: Expr,
diff --git a/src/expr/case.rs b/src/expr/case.rs
index 92e28ba..1a73698 100644
--- a/src/expr/case.rs
+++ b/src/expr/case.rs
@@ -19,7 +19,7 @@
 use datafusion::logical_expr::Case;
 use pyo3::prelude::*;
 
-#[pyclass(name = "Case", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Case", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyCase {
     case: Case,
diff --git a/src/expr/cast.rs b/src/expr/cast.rs
index b8faea6..03e2b84 100644
--- a/src/expr/cast.rs
+++ b/src/expr/cast.rs
@@ -19,7 +19,7 @@
 use datafusion::logical_expr::{Cast, TryCast};
 use pyo3::prelude::*;
 
-#[pyclass(name = "Cast", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Cast", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyCast {
     cast: Cast,
diff --git a/src/expr/column.rs b/src/expr/column.rs
index 50f316f..3000794 100644
--- a/src/expr/column.rs
+++ b/src/expr/column.rs
@@ -18,7 +18,7 @@
 use datafusion::common::Column;
 use pyo3::prelude::*;
 
-#[pyclass(name = "Column", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Column", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyColumn {
     pub col: Column,
diff --git a/src/expr/conditional_expr.rs b/src/expr/conditional_expr.rs
index fe3af2e..21f538b 100644
--- a/src/expr/conditional_expr.rs
+++ b/src/expr/conditional_expr.rs
@@ -17,38 +17,60 @@
 
 use crate::{errors::PyDataFusionResult, expr::PyExpr};
 use datafusion::logical_expr::conditional_expressions::CaseBuilder;
+use datafusion::prelude::Expr;
 use pyo3::prelude::*;
 
-#[pyclass(name = "CaseBuilder", module = "datafusion.expr", subclass)]
+// TODO(tsaucer) replace this all with CaseBuilder after it implements Clone
+#[derive(Clone, Debug)]
+#[pyclass(name = "CaseBuilder", module = "datafusion.expr", subclass, frozen)]
 pub struct PyCaseBuilder {
-    pub case_builder: CaseBuilder,
-}
-
-impl From<PyCaseBuilder> for CaseBuilder {
-    fn from(case_builder: PyCaseBuilder) -> Self {
-        case_builder.case_builder
-    }
-}
-
-impl From<CaseBuilder> for PyCaseBuilder {
-    fn from(case_builder: CaseBuilder) -> PyCaseBuilder {
-        PyCaseBuilder { case_builder }
-    }
+    expr: Option<Expr>,
+    when: Vec<Expr>,
+    then: Vec<Expr>,
 }
 
 #[pymethods]
 impl PyCaseBuilder {
-    fn when(&mut self, when: PyExpr, then: PyExpr) -> PyCaseBuilder {
-        PyCaseBuilder {
-            case_builder: self.case_builder.when(when.expr, then.expr),
+    #[new]
+    pub fn new(expr: Option<PyExpr>) -> Self {
+        Self {
+            expr: expr.map(Into::into),
+            when: vec![],
+            then: vec![],
         }
     }
 
-    fn otherwise(&mut self, else_expr: PyExpr) -> PyDataFusionResult<PyExpr> {
-        Ok(self.case_builder.otherwise(else_expr.expr)?.clone().into())
+    pub fn when(&self, when: PyExpr, then: PyExpr) -> PyCaseBuilder {
+        let mut case_builder = self.clone();
+        case_builder.when.push(when.into());
+        case_builder.then.push(then.into());
+
+        case_builder
     }
 
-    fn end(&mut self) -> PyDataFusionResult<PyExpr> {
-        Ok(self.case_builder.end()?.clone().into())
+    fn otherwise(&self, else_expr: PyExpr) -> PyDataFusionResult<PyExpr> {
+        let case_builder = CaseBuilder::new(
+            self.expr.clone().map(Box::new),
+            self.when.clone(),
+            self.then.clone(),
+            Some(Box::new(else_expr.into())),
+        );
+
+        let expr = case_builder.end()?;
+
+        Ok(expr.into())
+    }
+
+    fn end(&self) -> PyDataFusionResult<PyExpr> {
+        let case_builder = CaseBuilder::new(
+            self.expr.clone().map(Box::new),
+            self.when.clone(),
+            self.then.clone(),
+            None,
+        );
+
+        let expr = case_builder.end()?;
+
+        Ok(expr.into())
     }
 }
diff --git a/src/expr/copy_to.rs b/src/expr/copy_to.rs
index c2f7c61..422ab77 100644
--- a/src/expr/copy_to.rs
+++ b/src/expr/copy_to.rs
@@ -28,7 +28,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "CopyTo", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "CopyTo", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyCopyTo {
     copy: CopyTo,
@@ -114,7 +114,7 @@
     }
 }
 
-#[pyclass(name = "FileType", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "FileType", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyFileType {
     file_type: Arc<dyn FileType>,
diff --git a/src/expr/create_catalog.rs b/src/expr/create_catalog.rs
index d2d2ee8..3613878 100644
--- a/src/expr/create_catalog.rs
+++ b/src/expr/create_catalog.rs
@@ -27,7 +27,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "CreateCatalog", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "CreateCatalog", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyCreateCatalog {
     create: CreateCatalog,
diff --git a/src/expr/create_catalog_schema.rs b/src/expr/create_catalog_schema.rs
index e794962..cb3be2d 100644
--- a/src/expr/create_catalog_schema.rs
+++ b/src/expr/create_catalog_schema.rs
@@ -27,7 +27,12 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "CreateCatalogSchema", module = "datafusion.expr", subclass)]
+#[pyclass(
+    frozen,
+    name = "CreateCatalogSchema",
+    module = "datafusion.expr",
+    subclass
+)]
 #[derive(Clone)]
 pub struct PyCreateCatalogSchema {
     create: CreateCatalogSchema,
diff --git a/src/expr/create_external_table.rs b/src/expr/create_external_table.rs
index 3e35af0..920d0d6 100644
--- a/src/expr/create_external_table.rs
+++ b/src/expr/create_external_table.rs
@@ -29,7 +29,12 @@
 
 use super::{logical_node::LogicalNode, sort_expr::PySortExpr};
 
-#[pyclass(name = "CreateExternalTable", module = "datafusion.expr", subclass)]
+#[pyclass(
+    frozen,
+    name = "CreateExternalTable",
+    module = "datafusion.expr",
+    subclass
+)]
 #[derive(Clone)]
 pub struct PyCreateExternalTable {
     create: CreateExternalTable,
diff --git a/src/expr/create_function.rs b/src/expr/create_function.rs
index c02ceeb..1b663b4 100644
--- a/src/expr/create_function.rs
+++ b/src/expr/create_function.rs
@@ -30,7 +30,7 @@
 use crate::common::{data_type::PyDataType, df_schema::PyDFSchema};
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "CreateFunction", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "CreateFunction", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyCreateFunction {
     create: CreateFunction,
@@ -54,21 +54,31 @@
     }
 }
 
-#[pyclass(name = "OperateFunctionArg", module = "datafusion.expr", subclass)]
+#[pyclass(
+    frozen,
+    name = "OperateFunctionArg",
+    module = "datafusion.expr",
+    subclass
+)]
 #[derive(Clone)]
 pub struct PyOperateFunctionArg {
     arg: OperateFunctionArg,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
-#[pyclass(eq, eq_int, name = "Volatility", module = "datafusion.expr")]
+#[pyclass(frozen, eq, eq_int, name = "Volatility", module = "datafusion.expr")]
 pub enum PyVolatility {
     Immutable,
     Stable,
     Volatile,
 }
 
-#[pyclass(name = "CreateFunctionBody", module = "datafusion.expr", subclass)]
+#[pyclass(
+    frozen,
+    name = "CreateFunctionBody",
+    module = "datafusion.expr",
+    subclass
+)]
 #[derive(Clone)]
 pub struct PyCreateFunctionBody {
     body: CreateFunctionBody,
diff --git a/src/expr/create_index.rs b/src/expr/create_index.rs
index 0f4b501..7b68df3 100644
--- a/src/expr/create_index.rs
+++ b/src/expr/create_index.rs
@@ -27,7 +27,7 @@
 
 use super::{logical_node::LogicalNode, sort_expr::PySortExpr};
 
-#[pyclass(name = "CreateIndex", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "CreateIndex", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyCreateIndex {
     create: CreateIndex,
diff --git a/src/expr/create_memory_table.rs b/src/expr/create_memory_table.rs
index 37f4d34..15aaa81 100644
--- a/src/expr/create_memory_table.rs
+++ b/src/expr/create_memory_table.rs
@@ -24,7 +24,12 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "CreateMemoryTable", module = "datafusion.expr", subclass)]
+#[pyclass(
+    frozen,
+    name = "CreateMemoryTable",
+    module = "datafusion.expr",
+    subclass
+)]
 #[derive(Clone)]
 pub struct PyCreateMemoryTable {
     create: CreateMemoryTable,
diff --git a/src/expr/create_view.rs b/src/expr/create_view.rs
index 718e404..49b3b61 100644
--- a/src/expr/create_view.rs
+++ b/src/expr/create_view.rs
@@ -24,7 +24,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "CreateView", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "CreateView", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyCreateView {
     create: CreateView,
diff --git a/src/expr/describe_table.rs b/src/expr/describe_table.rs
index 6c48f3c..315026f 100644
--- a/src/expr/describe_table.rs
+++ b/src/expr/describe_table.rs
@@ -28,7 +28,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "DescribeTable", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "DescribeTable", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyDescribeTable {
     describe: DescribeTable,
diff --git a/src/expr/distinct.rs b/src/expr/distinct.rs
index 889e709..5770b84 100644
--- a/src/expr/distinct.rs
+++ b/src/expr/distinct.rs
@@ -24,7 +24,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "Distinct", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Distinct", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyDistinct {
     distinct: Distinct,
diff --git a/src/expr/dml.rs b/src/expr/dml.rs
index 251e336..4437a9d 100644
--- a/src/expr/dml.rs
+++ b/src/expr/dml.rs
@@ -24,7 +24,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "DmlStatement", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "DmlStatement", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyDmlStatement {
     dml: DmlStatement,
diff --git a/src/expr/drop_catalog_schema.rs b/src/expr/drop_catalog_schema.rs
index b4a4c52..7008bcd 100644
--- a/src/expr/drop_catalog_schema.rs
+++ b/src/expr/drop_catalog_schema.rs
@@ -28,7 +28,12 @@
 use super::logical_node::LogicalNode;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "DropCatalogSchema", module = "datafusion.expr", subclass)]
+#[pyclass(
+    frozen,
+    name = "DropCatalogSchema",
+    module = "datafusion.expr",
+    subclass
+)]
 #[derive(Clone)]
 pub struct PyDropCatalogSchema {
     drop: DropCatalogSchema,
diff --git a/src/expr/drop_function.rs b/src/expr/drop_function.rs
index fca9eb9..42ad3e1 100644
--- a/src/expr/drop_function.rs
+++ b/src/expr/drop_function.rs
@@ -27,7 +27,7 @@
 use crate::common::df_schema::PyDFSchema;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "DropFunction", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "DropFunction", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyDropFunction {
     drop: DropFunction,
diff --git a/src/expr/drop_table.rs b/src/expr/drop_table.rs
index 3f44253..6ff4f01 100644
--- a/src/expr/drop_table.rs
+++ b/src/expr/drop_table.rs
@@ -24,7 +24,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "DropTable", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "DropTable", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyDropTable {
     drop: DropTable,
diff --git a/src/expr/drop_view.rs b/src/expr/drop_view.rs
index 6196c8b..b2aff4e 100644
--- a/src/expr/drop_view.rs
+++ b/src/expr/drop_view.rs
@@ -28,7 +28,7 @@
 use super::logical_node::LogicalNode;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "DropView", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "DropView", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyDropView {
     drop: DropView,
diff --git a/src/expr/empty_relation.rs b/src/expr/empty_relation.rs
index 7582134..797a8c0 100644
--- a/src/expr/empty_relation.rs
+++ b/src/expr/empty_relation.rs
@@ -22,7 +22,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "EmptyRelation", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "EmptyRelation", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyEmptyRelation {
     empty: EmptyRelation,
diff --git a/src/expr/exists.rs b/src/expr/exists.rs
index 6933578..392bfcb 100644
--- a/src/expr/exists.rs
+++ b/src/expr/exists.rs
@@ -20,7 +20,7 @@
 
 use super::subquery::PySubquery;
 
-#[pyclass(name = "Exists", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Exists", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyExists {
     exists: Exists,
diff --git a/src/expr/explain.rs b/src/expr/explain.rs
index fc02fe2..71b7b2c 100644
--- a/src/expr/explain.rs
+++ b/src/expr/explain.rs
@@ -24,7 +24,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "Explain", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Explain", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyExplain {
     explain: Explain,
diff --git a/src/expr/extension.rs b/src/expr/extension.rs
index 1e3fbb1..7d913ff 100644
--- a/src/expr/extension.rs
+++ b/src/expr/extension.rs
@@ -22,7 +22,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "Extension", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Extension", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyExtension {
     pub node: Extension,
diff --git a/src/expr/filter.rs b/src/expr/filter.rs
index 4fcb600..76338d1 100644
--- a/src/expr/filter.rs
+++ b/src/expr/filter.rs
@@ -24,7 +24,7 @@
 use crate::expr::PyExpr;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "Filter", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Filter", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyFilter {
     filter: Filter,
diff --git a/src/expr/grouping_set.rs b/src/expr/grouping_set.rs
index 63a1c0b..107dd93 100644
--- a/src/expr/grouping_set.rs
+++ b/src/expr/grouping_set.rs
@@ -18,7 +18,7 @@
 use datafusion::logical_expr::GroupingSet;
 use pyo3::prelude::*;
 
-#[pyclass(name = "GroupingSet", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "GroupingSet", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyGroupingSet {
     grouping_set: GroupingSet,
diff --git a/src/expr/in_list.rs b/src/expr/in_list.rs
index 5dfd8d8..e2e6d78 100644
--- a/src/expr/in_list.rs
+++ b/src/expr/in_list.rs
@@ -19,7 +19,7 @@
 use datafusion::logical_expr::expr::InList;
 use pyo3::prelude::*;
 
-#[pyclass(name = "InList", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "InList", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyInList {
     in_list: InList,
diff --git a/src/expr/in_subquery.rs b/src/expr/in_subquery.rs
index 306b68a..6d4a38e 100644
--- a/src/expr/in_subquery.rs
+++ b/src/expr/in_subquery.rs
@@ -20,7 +20,7 @@
 
 use super::{subquery::PySubquery, PyExpr};
 
-#[pyclass(name = "InSubquery", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "InSubquery", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyInSubquery {
     in_subquery: InSubquery,
diff --git a/src/expr/indexed_field.rs b/src/expr/indexed_field.rs
index a22dc6b..1dfa0ed 100644
--- a/src/expr/indexed_field.rs
+++ b/src/expr/indexed_field.rs
@@ -22,7 +22,7 @@
 
 use super::literal::PyLiteral;
 
-#[pyclass(name = "GetIndexedField", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "GetIndexedField", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyGetIndexedField {
     indexed_field: GetIndexedField,
diff --git a/src/expr/join.rs b/src/expr/join.rs
index 7b7e0d9..3fde874 100644
--- a/src/expr/join.rs
+++ b/src/expr/join.rs
@@ -25,7 +25,7 @@
 use crate::sql::logical::PyLogicalPlan;
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
-#[pyclass(name = "JoinType", module = "datafusion.expr")]
+#[pyclass(frozen, name = "JoinType", module = "datafusion.expr")]
 pub struct PyJoinType {
     join_type: JoinType,
 }
@@ -60,7 +60,7 @@
 }
 
 #[derive(Debug, Clone, Copy)]
-#[pyclass(name = "JoinConstraint", module = "datafusion.expr")]
+#[pyclass(frozen, name = "JoinConstraint", module = "datafusion.expr")]
 pub struct PyJoinConstraint {
     join_constraint: JoinConstraint,
 }
@@ -87,7 +87,7 @@
     }
 }
 
-#[pyclass(name = "Join", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Join", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyJoin {
     join: Join,
diff --git a/src/expr/like.rs b/src/expr/like.rs
index f180f5d..0a36dcd 100644
--- a/src/expr/like.rs
+++ b/src/expr/like.rs
@@ -21,7 +21,7 @@
 
 use crate::expr::PyExpr;
 
-#[pyclass(name = "Like", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Like", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyLike {
     like: Like,
@@ -79,7 +79,7 @@
     }
 }
 
-#[pyclass(name = "ILike", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "ILike", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyILike {
     like: Like,
@@ -137,7 +137,7 @@
     }
 }
 
-#[pyclass(name = "SimilarTo", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "SimilarTo", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PySimilarTo {
     like: Like,
diff --git a/src/expr/limit.rs b/src/expr/limit.rs
index 9255281..cf6971f 100644
--- a/src/expr/limit.rs
+++ b/src/expr/limit.rs
@@ -23,7 +23,7 @@
 use crate::expr::logical_node::LogicalNode;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "Limit", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Limit", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyLimit {
     limit: Limit,
diff --git a/src/expr/literal.rs b/src/expr/literal.rs
index 561242c..8a589b5 100644
--- a/src/expr/literal.rs
+++ b/src/expr/literal.rs
@@ -19,7 +19,7 @@
 use datafusion::{common::ScalarValue, logical_expr::expr::FieldMetadata};
 use pyo3::{prelude::*, IntoPyObjectExt};
 
-#[pyclass(name = "Literal", module = "datafusion.expr", subclass)]
+#[pyclass(name = "Literal", module = "datafusion.expr", subclass, frozen)]
 #[derive(Clone)]
 pub struct PyLiteral {
     pub value: ScalarValue,
@@ -71,7 +71,7 @@
         extract_scalar_value!(self, Float64)
     }
 
-    pub fn value_decimal128(&mut self) -> PyResult<(Option<i128>, u8, i8)> {
+    pub fn value_decimal128(&self) -> PyResult<(Option<i128>, u8, i8)> {
         match &self.value {
             ScalarValue::Decimal128(value, precision, scale) => Ok((*value, *precision, *scale)),
             other => Err(unexpected_literal_value(other)),
@@ -122,7 +122,7 @@
         extract_scalar_value!(self, Time64Nanosecond)
     }
 
-    pub fn value_timestamp(&mut self) -> PyResult<(Option<i64>, Option<String>)> {
+    pub fn value_timestamp(&self) -> PyResult<(Option<i64>, Option<String>)> {
         match &self.value {
             ScalarValue::TimestampNanosecond(iv, tz)
             | ScalarValue::TimestampMicrosecond(iv, tz)
diff --git a/src/expr/placeholder.rs b/src/expr/placeholder.rs
index 4ac2c47..268263d 100644
--- a/src/expr/placeholder.rs
+++ b/src/expr/placeholder.rs
@@ -20,7 +20,7 @@
 
 use crate::common::data_type::PyDataType;
 
-#[pyclass(name = "Placeholder", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Placeholder", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyPlaceholder {
     placeholder: Placeholder,
diff --git a/src/expr/projection.rs b/src/expr/projection.rs
index b5a9ef3..b2d5db7 100644
--- a/src/expr/projection.rs
+++ b/src/expr/projection.rs
@@ -25,7 +25,7 @@
 use crate::expr::PyExpr;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "Projection", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Projection", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyProjection {
     pub projection: Projection,
diff --git a/src/expr/recursive_query.rs b/src/expr/recursive_query.rs
index 2517b74..fe04731 100644
--- a/src/expr/recursive_query.rs
+++ b/src/expr/recursive_query.rs
@@ -24,7 +24,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "RecursiveQuery", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "RecursiveQuery", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyRecursiveQuery {
     query: RecursiveQuery,
diff --git a/src/expr/repartition.rs b/src/expr/repartition.rs
index 48b5e70..ee6d1dc 100644
--- a/src/expr/repartition.rs
+++ b/src/expr/repartition.rs
@@ -24,13 +24,13 @@
 
 use super::{logical_node::LogicalNode, PyExpr};
 
-#[pyclass(name = "Repartition", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Repartition", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyRepartition {
     repartition: Repartition,
 }
 
-#[pyclass(name = "Partitioning", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Partitioning", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyPartitioning {
     partitioning: Partitioning,
diff --git a/src/expr/scalar_subquery.rs b/src/expr/scalar_subquery.rs
index 9d35f28..e58d66e 100644
--- a/src/expr/scalar_subquery.rs
+++ b/src/expr/scalar_subquery.rs
@@ -20,7 +20,7 @@
 
 use super::subquery::PySubquery;
 
-#[pyclass(name = "ScalarSubquery", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "ScalarSubquery", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyScalarSubquery {
     subquery: Subquery,
diff --git a/src/expr/scalar_variable.rs b/src/expr/scalar_variable.rs
index 7b50ba2..f3c128a 100644
--- a/src/expr/scalar_variable.rs
+++ b/src/expr/scalar_variable.rs
@@ -20,7 +20,7 @@
 
 use crate::common::data_type::PyDataType;
 
-#[pyclass(name = "ScalarVariable", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "ScalarVariable", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyScalarVariable {
     data_type: DataType,
diff --git a/src/expr/signature.rs b/src/expr/signature.rs
index e857635..e2c23dc 100644
--- a/src/expr/signature.rs
+++ b/src/expr/signature.rs
@@ -19,7 +19,7 @@
 use pyo3::prelude::*;
 
 #[allow(dead_code)]
-#[pyclass(name = "Signature", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Signature", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PySignature {
     type_signature: TypeSignature,
diff --git a/src/expr/sort.rs b/src/expr/sort.rs
index 79a8aee..d5ea07f 100644
--- a/src/expr/sort.rs
+++ b/src/expr/sort.rs
@@ -25,7 +25,7 @@
 use crate::expr::sort_expr::PySortExpr;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "Sort", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Sort", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PySort {
     sort: Sort,
diff --git a/src/expr/sort_expr.rs b/src/expr/sort_expr.rs
index e2df6b9..3f27902 100644
--- a/src/expr/sort_expr.rs
+++ b/src/expr/sort_expr.rs
@@ -20,7 +20,7 @@
 use pyo3::prelude::*;
 use std::fmt::{self, Display, Formatter};
 
-#[pyclass(name = "SortExpr", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "SortExpr", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PySortExpr {
     pub(crate) sort: SortExpr,
diff --git a/src/expr/statement.rs b/src/expr/statement.rs
index 83774cd..1ea4f9f 100644
--- a/src/expr/statement.rs
+++ b/src/expr/statement.rs
@@ -25,7 +25,12 @@
 
 use super::{logical_node::LogicalNode, PyExpr};
 
-#[pyclass(name = "TransactionStart", module = "datafusion.expr", subclass)]
+#[pyclass(
+    frozen,
+    name = "TransactionStart",
+    module = "datafusion.expr",
+    subclass
+)]
 #[derive(Clone)]
 pub struct PyTransactionStart {
     transaction_start: TransactionStart,
@@ -56,7 +61,13 @@
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
-#[pyclass(eq, eq_int, name = "TransactionAccessMode", module = "datafusion.expr")]
+#[pyclass(
+    frozen,
+    eq,
+    eq_int,
+    name = "TransactionAccessMode",
+    module = "datafusion.expr"
+)]
 pub enum PyTransactionAccessMode {
     ReadOnly,
     ReadWrite,
@@ -84,6 +95,7 @@
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
 #[pyclass(
+    frozen,
     eq,
     eq_int,
     name = "TransactionIsolationLevel",
@@ -161,7 +173,7 @@
     }
 }
 
-#[pyclass(name = "TransactionEnd", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "TransactionEnd", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyTransactionEnd {
     transaction_end: TransactionEnd,
@@ -192,7 +204,13 @@
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
-#[pyclass(eq, eq_int, name = "TransactionConclusion", module = "datafusion.expr")]
+#[pyclass(
+    frozen,
+    eq,
+    eq_int,
+    name = "TransactionConclusion",
+    module = "datafusion.expr"
+)]
 pub enum PyTransactionConclusion {
     Commit,
     Rollback,
@@ -236,7 +254,7 @@
     }
 }
 
-#[pyclass(name = "SetVariable", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "SetVariable", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PySetVariable {
     set_variable: SetVariable,
@@ -284,7 +302,7 @@
     }
 }
 
-#[pyclass(name = "Prepare", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Prepare", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyPrepare {
     prepare: Prepare,
@@ -352,7 +370,7 @@
     }
 }
 
-#[pyclass(name = "Execute", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Execute", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyExecute {
     execute: Execute,
@@ -409,7 +427,7 @@
     }
 }
 
-#[pyclass(name = "Deallocate", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Deallocate", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyDeallocate {
     deallocate: Deallocate,
diff --git a/src/expr/subquery.rs b/src/expr/subquery.rs
index 77f56f9..785cf7d 100644
--- a/src/expr/subquery.rs
+++ b/src/expr/subquery.rs
@@ -24,7 +24,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "Subquery", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Subquery", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PySubquery {
     subquery: Subquery,
diff --git a/src/expr/subquery_alias.rs b/src/expr/subquery_alias.rs
index 3302e7f..ab1229b 100644
--- a/src/expr/subquery_alias.rs
+++ b/src/expr/subquery_alias.rs
@@ -24,7 +24,7 @@
 
 use super::logical_node::LogicalNode;
 
-#[pyclass(name = "SubqueryAlias", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "SubqueryAlias", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PySubqueryAlias {
     subquery_alias: SubqueryAlias,
diff --git a/src/expr/table_scan.rs b/src/expr/table_scan.rs
index 3299646..34a140d 100644
--- a/src/expr/table_scan.rs
+++ b/src/expr/table_scan.rs
@@ -24,7 +24,7 @@
 use crate::sql::logical::PyLogicalPlan;
 use crate::{common::df_schema::PyDFSchema, expr::PyExpr};
 
-#[pyclass(name = "TableScan", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "TableScan", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyTableScan {
     table_scan: TableScan,
diff --git a/src/expr/union.rs b/src/expr/union.rs
index e0b2213..b7b5896 100644
--- a/src/expr/union.rs
+++ b/src/expr/union.rs
@@ -23,7 +23,7 @@
 use crate::expr::logical_node::LogicalNode;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "Union", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Union", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyUnion {
     union_: Union,
diff --git a/src/expr/unnest.rs b/src/expr/unnest.rs
index c883334..7ed7919 100644
--- a/src/expr/unnest.rs
+++ b/src/expr/unnest.rs
@@ -23,7 +23,7 @@
 use crate::expr::logical_node::LogicalNode;
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "Unnest", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Unnest", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyUnnest {
     unnest_: Unnest,
diff --git a/src/expr/unnest_expr.rs b/src/expr/unnest_expr.rs
index 634186e..2cdf46a 100644
--- a/src/expr/unnest_expr.rs
+++ b/src/expr/unnest_expr.rs
@@ -21,7 +21,7 @@
 
 use super::PyExpr;
 
-#[pyclass(name = "UnnestExpr", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "UnnestExpr", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyUnnestExpr {
     unnest: Unnest,
diff --git a/src/expr/values.rs b/src/expr/values.rs
index fb26922..63d94ce 100644
--- a/src/expr/values.rs
+++ b/src/expr/values.rs
@@ -25,7 +25,7 @@
 
 use super::{logical_node::LogicalNode, PyExpr};
 
-#[pyclass(name = "Values", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "Values", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyValues {
     values: Values,
diff --git a/src/expr/window.rs b/src/expr/window.rs
index 77ecb71..2723007 100644
--- a/src/expr/window.rs
+++ b/src/expr/window.rs
@@ -30,13 +30,13 @@
 
 use super::py_expr_list;
 
-#[pyclass(name = "WindowExpr", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "WindowExpr", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyWindowExpr {
     window: Window,
 }
 
-#[pyclass(name = "WindowFrame", module = "datafusion.expr", subclass)]
+#[pyclass(frozen, name = "WindowFrame", module = "datafusion.expr", subclass)]
 #[derive(Clone)]
 pub struct PyWindowFrame {
     window_frame: WindowFrame,
@@ -54,7 +54,12 @@
     }
 }
 
-#[pyclass(name = "WindowFrameBound", module = "datafusion.expr", subclass)]
+#[pyclass(
+    frozen,
+    name = "WindowFrameBound",
+    module = "datafusion.expr",
+    subclass
+)]
 #[derive(Clone)]
 pub struct PyWindowFrameBound {
     frame_bound: WindowFrameBound,
diff --git a/src/functions.rs b/src/functions.rs
index e92cf05..5956b67 100644
--- a/src/functions.rs
+++ b/src/functions.rs
@@ -230,17 +230,13 @@
 /// Create a CASE WHEN statement with literal WHEN expressions for comparison to the base expression.
 #[pyfunction]
 fn case(expr: PyExpr) -> PyResult<PyCaseBuilder> {
-    Ok(PyCaseBuilder {
-        case_builder: datafusion::logical_expr::case(expr.expr),
-    })
+    Ok(PyCaseBuilder::new(Some(expr)))
 }
 
 /// Create a CASE WHEN statement with literal WHEN expressions for comparison to the base expression.
 #[pyfunction]
 fn when(when: PyExpr, then: PyExpr) -> PyResult<PyCaseBuilder> {
-    Ok(PyCaseBuilder {
-        case_builder: datafusion::logical_expr::when(when.expr, then.expr),
-    })
+    Ok(PyCaseBuilder::new(None).when(when, then))
 }
 
 /// Helper function to find the appropriate window function.
diff --git a/src/physical_plan.rs b/src/physical_plan.rs
index 49db643..4994b01 100644
--- a/src/physical_plan.rs
+++ b/src/physical_plan.rs
@@ -24,7 +24,7 @@
 
 use crate::{context::PySessionContext, errors::PyDataFusionResult};
 
-#[pyclass(name = "ExecutionPlan", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "ExecutionPlan", module = "datafusion", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyExecutionPlan {
     pub plan: Arc<dyn ExecutionPlan>,
diff --git a/src/record_batch.rs b/src/record_batch.rs
index a85f054..c3658cf 100644
--- a/src/record_batch.rs
+++ b/src/record_batch.rs
@@ -28,7 +28,7 @@
 use pyo3::{pyclass, pymethods, PyObject, PyResult, Python};
 use tokio::sync::Mutex;
 
-#[pyclass(name = "RecordBatch", module = "datafusion", subclass)]
+#[pyclass(name = "RecordBatch", module = "datafusion", subclass, frozen)]
 pub struct PyRecordBatch {
     batch: RecordBatch,
 }
@@ -46,7 +46,7 @@
     }
 }
 
-#[pyclass(name = "RecordBatchStream", module = "datafusion", subclass)]
+#[pyclass(name = "RecordBatchStream", module = "datafusion", subclass, frozen)]
 pub struct PyRecordBatchStream {
     stream: Arc<Mutex<SendableRecordBatchStream>>,
 }
@@ -61,12 +61,12 @@
 
 #[pymethods]
 impl PyRecordBatchStream {
-    fn next(&mut self, py: Python) -> PyResult<PyRecordBatch> {
+    fn next(&self, py: Python) -> PyResult<PyRecordBatch> {
         let stream = self.stream.clone();
         wait_for_future(py, next_stream(stream, true))?
     }
 
-    fn __next__(&mut self, py: Python) -> PyResult<PyRecordBatch> {
+    fn __next__(&self, py: Python) -> PyResult<PyRecordBatch> {
         self.next(py)
     }
 
diff --git a/src/sql/logical.rs b/src/sql/logical.rs
index 97d3204..47ea39f 100644
--- a/src/sql/logical.rs
+++ b/src/sql/logical.rs
@@ -63,7 +63,7 @@
 
 use crate::expr::logical_node::LogicalNode;
 
-#[pyclass(name = "LogicalPlan", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "LogicalPlan", module = "datafusion", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyLogicalPlan {
     pub(crate) plan: Arc<LogicalPlan>,
diff --git a/src/store.rs b/src/store.rs
index 1e5fab4..9986818 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -36,7 +36,12 @@
     HTTP(PyHttpContext),
 }
 
-#[pyclass(name = "LocalFileSystem", module = "datafusion.store", subclass)]
+#[pyclass(
+    frozen,
+    name = "LocalFileSystem",
+    module = "datafusion.store",
+    subclass
+)]
 #[derive(Debug, Clone)]
 pub struct PyLocalFileSystemContext {
     pub inner: Arc<LocalFileSystem>,
@@ -62,7 +67,7 @@
     }
 }
 
-#[pyclass(name = "MicrosoftAzure", module = "datafusion.store", subclass)]
+#[pyclass(frozen, name = "MicrosoftAzure", module = "datafusion.store", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyMicrosoftAzureContext {
     pub inner: Arc<MicrosoftAzure>,
@@ -134,7 +139,7 @@
     }
 }
 
-#[pyclass(name = "GoogleCloud", module = "datafusion.store", subclass)]
+#[pyclass(frozen, name = "GoogleCloud", module = "datafusion.store", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyGoogleCloudContext {
     pub inner: Arc<GoogleCloudStorage>,
@@ -164,7 +169,7 @@
     }
 }
 
-#[pyclass(name = "AmazonS3", module = "datafusion.store", subclass)]
+#[pyclass(frozen, name = "AmazonS3", module = "datafusion.store", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyAmazonS3Context {
     pub inner: Arc<AmazonS3>,
@@ -223,7 +228,7 @@
     }
 }
 
-#[pyclass(name = "Http", module = "datafusion.store", subclass)]
+#[pyclass(frozen, name = "Http", module = "datafusion.store", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyHttpContext {
     pub url: String,
diff --git a/src/substrait.rs b/src/substrait.rs
index f1936b0..291892c 100644
--- a/src/substrait.rs
+++ b/src/substrait.rs
@@ -27,7 +27,7 @@
 use datafusion_substrait::substrait::proto::Plan;
 use prost::Message;
 
-#[pyclass(name = "Plan", module = "datafusion.substrait", subclass)]
+#[pyclass(frozen, name = "Plan", module = "datafusion.substrait", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyPlan {
     pub plan: Plan,
@@ -59,7 +59,7 @@
 /// A PySubstraitSerializer is a representation of a Serializer that is capable of both serializing
 /// a `LogicalPlan` instance to Substrait Protobuf bytes and also deserialize Substrait Protobuf bytes
 /// to a valid `LogicalPlan` instance.
-#[pyclass(name = "Serde", module = "datafusion.substrait", subclass)]
+#[pyclass(frozen, name = "Serde", module = "datafusion.substrait", subclass)]
 #[derive(Debug, Clone)]
 pub struct PySubstraitSerializer;
 
@@ -112,7 +112,7 @@
     }
 }
 
-#[pyclass(name = "Producer", module = "datafusion.substrait", subclass)]
+#[pyclass(frozen, name = "Producer", module = "datafusion.substrait", subclass)]
 #[derive(Debug, Clone)]
 pub struct PySubstraitProducer;
 
@@ -129,7 +129,7 @@
     }
 }
 
-#[pyclass(name = "Consumer", module = "datafusion.substrait", subclass)]
+#[pyclass(frozen, name = "Consumer", module = "datafusion.substrait", subclass)]
 #[derive(Debug, Clone)]
 pub struct PySubstraitConsumer;
 
diff --git a/src/udaf.rs b/src/udaf.rs
index 78f4e2b..eab4581 100644
--- a/src/udaf.rs
+++ b/src/udaf.rs
@@ -155,7 +155,7 @@
 }
 
 /// Represents an AggregateUDF
-#[pyclass(name = "AggregateUDF", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "AggregateUDF", module = "datafusion", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyAggregateUDF {
     pub(crate) function: AggregateUDF,
diff --git a/src/udf.rs b/src/udf.rs
index de1e3f1..a9249d6 100644
--- a/src/udf.rs
+++ b/src/udf.rs
@@ -81,7 +81,7 @@
 }
 
 /// Represents a PyScalarUDF
-#[pyclass(name = "ScalarUDF", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "ScalarUDF", module = "datafusion", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyScalarUDF {
     pub(crate) function: ScalarUDF,
diff --git a/src/udtf.rs b/src/udtf.rs
index db16d6c..55f306b 100644
--- a/src/udtf.rs
+++ b/src/udtf.rs
@@ -31,7 +31,7 @@
 use pyo3::types::{PyCapsule, PyTuple};
 
 /// Represents a user defined table function
-#[pyclass(name = "TableFunction", module = "datafusion")]
+#[pyclass(frozen, name = "TableFunction", module = "datafusion")]
 #[derive(Debug, Clone)]
 pub struct PyTableFunction {
     pub(crate) name: String,
diff --git a/src/udwf.rs b/src/udwf.rs
index 70a66e3..ceeaa0e 100644
--- a/src/udwf.rs
+++ b/src/udwf.rs
@@ -210,7 +210,7 @@
 }
 
 /// Represents an WindowUDF
-#[pyclass(name = "WindowUDF", module = "datafusion", subclass)]
+#[pyclass(frozen, name = "WindowUDF", module = "datafusion", subclass)]
 #[derive(Debug, Clone)]
 pub struct PyWindowUDF {
     pub(crate) function: WindowUDF,
diff --git a/src/unparser/dialect.rs b/src/unparser/dialect.rs
index caeef99..5df0a0c 100644
--- a/src/unparser/dialect.rs
+++ b/src/unparser/dialect.rs
@@ -22,7 +22,7 @@
 };
 use pyo3::prelude::*;
 
-#[pyclass(name = "Dialect", module = "datafusion.unparser", subclass)]
+#[pyclass(frozen, name = "Dialect", module = "datafusion.unparser", subclass)]
 #[derive(Clone)]
 pub struct PyDialect {
     pub dialect: Arc<dyn Dialect>,
diff --git a/src/unparser/mod.rs b/src/unparser/mod.rs
index b4b0fed..f234345 100644
--- a/src/unparser/mod.rs
+++ b/src/unparser/mod.rs
@@ -25,7 +25,7 @@
 
 use crate::sql::logical::PyLogicalPlan;
 
-#[pyclass(name = "Unparser", module = "datafusion.unparser", subclass)]
+#[pyclass(frozen, name = "Unparser", module = "datafusion.unparser", subclass)]
 #[derive(Clone)]
 pub struct PyUnparser {
     dialect: Arc<dyn Dialect>,