diff --git a/src/marshmallow_dataframe/dataframe.py b/src/marshmallow_dataframe/dataframe.py
index 66369c5..7e612a9 100644
--- a/src/marshmallow_dataframe/dataframe.py
+++ b/src/marshmallow_dataframe/dataframe.py
@@ -1,3 +1,5 @@
+from collections import defaultdict
+
 import pandas as pd
 import numpy as np
 import marshmallow as ma
@@ -5,18 +7,29 @@ from typing import NamedTuple, List, Union, Dict
 
 __all__ = ["Dtypes", "RecordsDataFrameSchema", "SplitDataFrameSchema"]
 
-_FIELD_OPTIONS = {"required": True, "allow_none": True}
-
-# Integer columns in pandas cannot have null values, so here we allow_none for
-# all types except int
-DTYPE_KIND_TO_FIELD = {
-    "i": ma.fields.Int(required=True),
-    "u": ma.fields.Int(**_FIELD_OPTIONS),
-    "f": ma.fields.Float(allow_nan=True, **_FIELD_OPTIONS),
-    "O": ma.fields.Str(**_FIELD_OPTIONS),
-    "b": ma.fields.Bool(**_FIELD_OPTIONS),
-    "M": ma.fields.DateTime(**_FIELD_OPTIONS),
-    "m": ma.fields.TimeDelta(**_FIELD_OPTIONS),
+DTYPE_KIND_TO_FIELD_CLASS = {
+    "i": ma.fields.Int,
+    "u": ma.fields.Int,
+    "f": ma.fields.Float,
+    "O": ma.fields.Str,
+    "U": ma.fields.Str,
+    "S": ma.fields.Str,
+    "b": ma.fields.Bool,
+    "M": ma.fields.DateTime,
+    "m": ma.fields.TimeDelta,
+}
+
+_DEFAULT_FIELD_OPTIONS = {"required": True, "allow_none": True}
+
+DTYPE_KIND_TO_FIELD_OPTIONS: Dict[str, Dict[str, bool]] = defaultdict(
+    lambda: _DEFAULT_FIELD_OPTIONS
+)
+# Integer columns in pandas cannot have null values, so we allow_none for all
+# types except int
+DTYPE_KIND_TO_FIELD_OPTIONS["i"] = {"required": True}
+DTYPE_KIND_TO_FIELD_OPTIONS["f"] = {
+    "allow_nan": True,
+    **_DEFAULT_FIELD_OPTIONS,
 }
 
 
@@ -25,7 +38,7 @@ class Dtypes(NamedTuple):
     dtypes: List[np.dtype]
 
     @classmethod
-    def from_pandas_dtypes(cls, pd_dtypes: pd.DataFrame) -> "Dtypes":
+    def from_pandas_dtypes(cls, pd_dtypes: pd.Series) -> "Dtypes":
         return cls(
             columns=list(pd_dtypes.index), dtypes=list(pd_dtypes.values)
         )
@@ -39,6 +52,16 @@ class DtypeToFieldConversionError(Exception):
 
 
 def _dtype_to_field(dtype: np.dtype) -> ma.fields.Field:
+    # Object dtypes require more detailed mapping
+    if pd.api.types.is_categorical_dtype(dtype):
+        categories = dtype.categories.values.tolist()
+        kind = dtype.categories.dtype.kind
+        field_class = DTYPE_KIND_TO_FIELD_CLASS[kind]
+        field_options = DTYPE_KIND_TO_FIELD_OPTIONS[kind]
+        return field_class(
+            validate=ma.validate.OneOf(categories), **field_options
+        )
+
     try:
         kind = dtype.kind
     except AttributeError as exc:
@@ -48,12 +71,14 @@ def _dtype_to_field(dtype: np.dtype) -> ma.fields.Field:
         ) from exc
 
     try:
-        return DTYPE_KIND_TO_FIELD[kind]
+        field_class = DTYPE_KIND_TO_FIELD_CLASS[kind]
+        field_options = DTYPE_KIND_TO_FIELD_OPTIONS[kind]
+        return field_class(**field_options)
     except KeyError as exc:
         raise DtypeToFieldConversionError(
             f"The conversion of the dtype {dtype} with kind {dtype.kind} "
             "into marshmallow fields is unknown. Known kinds are: "
-            f"{DTYPE_KIND_TO_FIELD.keys()}"
+            f"{DTYPE_KIND_TO_FIELD_CLASS.keys()}"
         ) from exc
 
 
@@ -205,14 +230,7 @@ class RecordsDataFrameSchema(ma.Schema, metaclass=RecordsDataFrameSchemaMeta):
         index_data = {i: row for i, row in enumerate(records_data)}
         return pd.DataFrame.from_dict(
             index_data, orient="index", columns=self.opts.dtypes.columns
-        ).astype(
-            {
-                k: v
-                for k, v in zip(
-                    self.opts.dtypes.columns, self.opts.dtypes.dtypes
-                )
-            }
-        )
+        ).astype(dict(zip(self.opts.dtypes.columns, self.opts.dtypes.dtypes)))
 
 
 class SplitDataFrameSchema(ma.Schema, metaclass=SplitDataFrameSchemaMeta):
@@ -229,4 +247,8 @@ class SplitDataFrameSchema(ma.Schema, metaclass=SplitDataFrameSchemaMeta):
 
     @ma.post_load
     def make_df(self, data: dict) -> pd.DataFrame:
-        return pd.DataFrame(dtype=None, **data)
+        df = pd.DataFrame(dtype=None, **data).astype(
+            dict(zip(self.opts.dtypes.columns, self.opts.dtypes.dtypes))
+        )
+
+        return df

Support categorical columns
Right now categorical columns are always recognised as a string dtype, which could be wrong. In addition, we are not validating that the deserialised values are one of the category options.
diff --git a/tests/test_dataframe.py b/tests/test_dataframe.py
index a87f974..3d28c9f 100644
--- a/tests/test_dataframe.py
+++ b/tests/test_dataframe.py
@@ -23,9 +23,14 @@ def sample_df():
             "string": ["a", "b"],
             "float": [1.1, 2.2],
             "datetime": ["2018-01-01T12:00:00Z", "2018-01-02T12:00:00Z"],
+            "categorical_int": [0, 1],
+            "categorical_str": ["zero", "one"],
         }
     )
 
+    df["categorical_int"] = df["categorical_int"].astype("category")
+    df["categorical_str"] = df["categorical_str"].astype("category")
+
     df["datetime"] = pd.to_datetime(df["datetime"]).astype("datetime64[ns]")
 
     return df
@@ -252,6 +257,31 @@ def test_records_schema_missing_data_field():
     )
 
 
+@pytest.mark.parametrize(
+    "column,data",
+    (["categorical_int", [0, 10]], ["categorical_str", ["one", "ten"]]),
+)
+def test_records_schema_wrong_category(sample_df, column, data):
+    class MySchema(RecordsDataFrameSchema):
+        class Meta:
+            dtypes = sample_df.dtypes
+
+    schema = MySchema()
+
+    # Replace categorical column data
+    new_df = sample_df.copy()
+    new_df[column] = data
+
+    input_df = serialize_df(new_df, orient="records")
+
+    with pytest.raises(ValidationError) as exception:
+        schema.load(input_df)
+
+    assert exception.value.messages["data"][1][column][0].startswith(
+        "Must be one of"
+    )
+
+
 @pytest.fixture
 def split_sample_schema(sample_df):
     class MySchema(SplitDataFrameSchema):
@@ -429,3 +459,26 @@ def test_split_schema_index_data_length_mismatch(
         exc.value.messages["data"][0]
         == f"Length of `index` and `data` must be equal."
     )
+
+
+@pytest.mark.parametrize(
+    "column,data",
+    (["categorical_int", [0, 10]], ["categorical_str", ["one", "ten"]]),
+)
+def test_split_schema_wrong_category(
+    split_sample_schema, sample_df, column, data
+):
+    # Replace categorical column data
+    new_df = sample_df.copy()
+    new_df[column] = data
+
+    column_index = list(new_df.columns).index(column)
+
+    input_df = serialize_df(new_df, orient="split")
+
+    with pytest.raises(ValidationError) as exception:
+        split_sample_schema.load(input_df)
+
+    assert exception.value.messages["data"][1][column_index][0].startswith(
+        "Must be one of"
+    )

pip install -e .
parse_log_pytest
pytest
pytest hypothesis[numpy]
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
certifi==2021.5.30
hypothesis==6.31.6
importlib-metadata @ file:///tmp/build/80754af9/importlib-metadata_1631916693255/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
marshmallow==3.14.1
-e git+https://github.com/facultyai/marshmallow-dataframe.git@bc95e84ca99a00b38107e747b0b7a9ed83362b04#egg=marshmallow_dataframe
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
numpy==1.19.5
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
pandas==1.1.5
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pytest==6.2.4
python-dateutil==2.9.0.post0
pytz==2025.2
six==1.17.0
sortedcontainers==2.4.0
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: marshmallow-dataframe
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib-metadata=4.8.1=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - hypothesis==6.31.6
      - marshmallow==3.14.1
      - numpy==1.19.5
      - pandas==1.1.5
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - six==1.17.0
      - sortedcontainers==2.4.0
prefix: /opt/conda/envs/marshmallow-dataframe

tests/test_dataframe.py::test_records_schema_wrong_category[categorical_int-data0] tests/test_dataframe.py::test_records_schema_wrong_category[categorical_str-data1] tests/test_dataframe.py::test_split_schema_wrong_category[categorical_int-data0] tests/test_dataframe.py::test_split_schema_wrong_category[categorical_str-data1]
tests/test_dataframe.py::test_records_schema tests/test_dataframe.py::test_records_schema_hypothesis tests/test_dataframe.py::test_records_schema_nulls tests/test_dataframe.py::test_split_schema tests/test_dataframe.py::test_split_schema_hypothesis tests/test_dataframe.py::test_split_schema_str_index tests/test_dataframe.py::test_split_schema_index_data_length_mismatch[index] tests/test_dataframe.py::test_split_schema_index_data_length_mismatch[data]
tests/test_dataframe.py::test_schema_no_dtypes[SplitDataFrameSchema] tests/test_dataframe.py::test_schema_no_dtypes[RecordsDataFrameSchema] tests/test_dataframe.py::test_schema_wrong_dtypes[SplitDataFrameSchema] tests/test_dataframe.py::test_schema_wrong_dtypes[RecordsDataFrameSchema] tests/test_dataframe.py::test_records_schema_missing_column tests/test_dataframe.py::test_records_schema_wrong_type tests/test_dataframe.py::test_records_schema_invalid_input_type_iter[input_data0] tests/test_dataframe.py::test_records_schema_invalid_input_type_iter[input_data1] tests/test_dataframe.py::test_records_schema_invalid_input_type_iter[input_data2] tests/test_dataframe.py::test_records_schema_invalid_input_type_notiter[] tests/test_dataframe.py::test_records_schema_invalid_input_type_notiter[string] tests/test_dataframe.py::test_records_schema_invalid_input_type_notiter[123] tests/test_dataframe.py::test_records_schema_invalid_input_type_notiter[True] tests/test_dataframe.py::test_records_schema_invalid_input_type_notiter[input_data4] tests/test_dataframe.py::test_records_schema_none tests/test_dataframe.py::test_records_schema_missing_data_field tests/test_dataframe.py::test_split_schema_missing_top_level_key[data] tests/test_dataframe.py::test_split_schema_missing_top_level_key[index] tests/test_dataframe.py::test_split_schema_missing_top_level_key[columns] tests/test_dataframe.py::test_split_schema_missing_column tests/test_dataframe.py::test_split_schema_swapped_column tests/test_dataframe.py::test_split_schema_wrong_row_length tests/test_dataframe.py::test_split_schema_wrong_type_in_data
src/marshmallow_dataframe/dataframe.py
src/marshmallow_dataframe/dataframe.py