diff --git a/dataframe-th.cabal b/dataframe-th.cabal
--- a/dataframe-th.cabal
+++ b/dataframe-th.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.4
 name:               dataframe-th
-version:            2.3.0.0
+version:            2.3.1.0
 synopsis:           Record-based Template Haskell splices for the dataframe ecosystem.
 description:
     The IO-agnostic part of the @dataframe@ TH machinery — splices that
@@ -27,6 +27,7 @@
 library
     import:             warnings
     exposed-modules:
+                        DataFrame.TH.Identifier
                         DataFrame.TH.Records
                         DataFrame.Typed.TH.Records
     -- Internal record→schema TH; sealed (superseded publicly by
@@ -34,8 +35,7 @@
     other-modules:      DataFrame.Internal.Schema.TH
     build-depends:      base >= 4 && < 5,
                         containers >= 0.6.7 && < 0.10,
-                        dataframe-core >= 2.6 && < 2.7,
-                        dataframe-operations >= 2.6 && < 2.7,
+                        dataframe-core >= 2.7 && < 2.8,
                         dataframe-parsing >= 2.2 && < 2.3,
                         template-haskell >= 2.0 && < 3,
                         text >= 2.1 && < 3,
diff --git a/src/DataFrame/TH/Identifier.hs b/src/DataFrame/TH/Identifier.hs
new file mode 100644
--- /dev/null
+++ b/src/DataFrame/TH/Identifier.hs
@@ -0,0 +1,75 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Turning column names into valid Haskell identifiers for generated code.
+module DataFrame.TH.Identifier (
+    sanitize,
+    isHaskellIdentifier,
+    isReservedId,
+    isVarId,
+) where
+
+import qualified Data.Char as Char
+import qualified Data.Text as T
+
+-- | Haskell Report 2.4 reserved words.
+isReservedId :: T.Text -> Bool
+isReservedId t = case t of
+    "case" -> True
+    "class" -> True
+    "data" -> True
+    "default" -> True
+    "deriving" -> True
+    "do" -> True
+    "else" -> True
+    "foreign" -> True
+    "if" -> True
+    "import" -> True
+    "in" -> True
+    "infix" -> True
+    "infixl" -> True
+    "infixr" -> True
+    "instance" -> True
+    "let" -> True
+    "module" -> True
+    "newtype" -> True
+    "of" -> True
+    "then" -> True
+    "type" -> True
+    "where" -> True
+    _ -> False
+
+isVarId :: T.Text -> Bool
+isVarId t = case T.uncons t of
+    -- '_' is skipped on purpose so "Name" and "_Name_" cannot collide.
+    Just (c, _) -> Char.isLower c && Char.isAlpha c
+    Nothing -> False
+
+isHaskellIdentifier :: T.Text -> Bool
+isHaskellIdentifier t = Prelude.not (isVarId t) || isReservedId t
+
+sanitize :: T.Text -> T.Text
+sanitize t
+    | isValid = t
+    | isHaskellIdentifier t' = "_" <> t' <> "_"
+    | otherwise = t'
+  where
+    isValid =
+        Prelude.not (isHaskellIdentifier t)
+            && isVarId t
+            && T.all Char.isAlphaNum t
+    t' = T.map replaceInvalidCharacters . T.filter (Prelude.not . parentheses) $ t
+    replaceInvalidCharacters c
+        | Char.isUpper c = Char.toLower c
+        | Char.isSpace c = '_'
+        | Char.isPunctuation c = '_' -- '-' will also become a '_'
+        | Char.isSymbol c = '_'
+        | Char.isAlphaNum c = c -- Blanket condition
+        | otherwise = '_' -- If we're unsure we'll default to an underscore
+    parentheses c = case c of
+        '(' -> True
+        ')' -> True
+        '{' -> True
+        '}' -> True
+        '[' -> True
+        ']' -> True
+        _ -> False
diff --git a/src/DataFrame/TH/Records.hs b/src/DataFrame/TH/Records.hs
--- a/src/DataFrame/TH/Records.hs
+++ b/src/DataFrame/TH/Records.hs
@@ -41,7 +41,6 @@
 import qualified Language.Haskell.TH.Syntax as TH
 
 import DataFrame.Expression.Operators (col)
-import DataFrame.Functions (sanitize)
 import DataFrame.Internal.Column (columnTypeString)
 import DataFrame.Internal.DataFrame (
     DataFrame (..),
@@ -50,6 +49,7 @@
 import DataFrame.Internal.Expression (Expr)
 import DataFrame.Internal.Schema.TH (schemaTypeE)
 import DataFrame.Schema (Schema, makeSchema)
+import DataFrame.TH.Identifier (sanitize)
 import Prelude as P
 
 knownTypeNames :: M.Map String Name
