diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,9 @@
+## [_Unreleased_](https://github.com/freckle/freckle-app/compare/persistent-sql-lifted-v0.1.0.0...main)
+
+## [v0.0.0.1](https://github.com/freckle/freckle-app/compare/persistent-sql-lifted-v0.0.0.0...persistent-sql-lifted-v0.1.0.0)
+
+Major expansion, adding query runners for Persistent and Esqueleto.
+
+## [v0.0.0.0](https://github.com/freckle/freckle-app/tree/persistent-sql-lifted-v0.0.0.0/persistent-sql-lifted)
+
+First release, sprouted from `freckle-app-1.20.3.0`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2023-2024 Renaissance Learning Inc
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,30 @@
+# persistent-sql-lifted
+
+This package introduces two classes:
+
+- `MonadSqlBackend m`, for monadic contexts `m` in which a `SqlBackend` is available
+
+- `MonadSqlBackend db m`, for monadic contexts `m` in which we can execute a SQL
+  transaction of type `db a` and get a result `m a`. (The type `db` should have an
+  instance of `MonadSqlBackend.)
+
+Additionally, this package provides variants of query-running utilities from
+[persistent] and [esqueleto] which are
+
+1. Concretized to use `SqlBackend`;
+2. Generalized to a `MonadSqlBackend m` constraint rather than `ReaderT backend m`;
+3. Wrapped in [checkpointCallStack] so that exceptions will include call stacks.
+
+How to migrate from vanilla persistent/esqueleto:
+
+- Instead of [SqlPersistT], use a `MonadSqlBackend` constraint.
+- Define an instance of `MonadSqlTx` for your application Monad that specifies how
+  your application runs database transactions, e.g. by running [runSqlPool].
+- Instead of calling `runSqlPool` directly from the rest of your application code,
+  use the `runSqlTx` method from the `MonadSqlTx` class.
+
+  [checkpointCallStack]: https://hackage.haskell.org/package/annotated-exception-0.3.0.2/docs/Control-Exception-Annotated-UnliftIO.html
+  [esqueleto]: https://hackage.haskell.org/package/esqueleto
+  [persistent]: https://hackage.haskell.org/package/persistent
+  [runSqlPool]: https://hackage.haskell.org/package/persistent-2.14.6.3/docs/Database-Persist-Sql.html#v:runSqlPool
+  [SqlPersistT]: https://hackage.haskell.org/package/persistent-2.14.6.3/docs/Database-Persist-Sql.html#t:SqlPersistT
diff --git a/library/Database/Persist/Sql/Lifted.hs b/library/Database/Persist/Sql/Lifted.hs
new file mode 100644
--- /dev/null
+++ b/library/Database/Persist/Sql/Lifted.hs
@@ -0,0 +1,117 @@
+{-# LANGUAGE CPP #-}
+
+-- |
+--
+-- Re-exports from:
+--
+-- * "Database.Persist.Sql.Lifted.Core"
+-- * "Database.Persist.Sql.Lifted.Persistent"
+-- * "Database.Persist.Sql.Lifted.Esqueleto"
+--
+-- There are a few name conflicts between Persistent and Esqueleto. Where conflicts occur, this
+-- module gives preference to Esqueleto. The following Persistent definitions are renamed:
+--
+-- * 'Database.Persist.Sql.Lifted.Persistent.delete' -> 'deleteKey'
+-- * 'Database.Persist.Sql.Lifted.Persistent.update' -> 'update''
+module Database.Persist.Sql.Lifted
+  ( -- * Core concepts
+    MonadSqlTx (..)
+  , HasSqlBackend (..)
+  , SqlBackend
+  , MonadSqlBackend (..)
+  , liftSql
+
+    -- * Getting by key
+  , get
+  , getBy
+  , getByValue
+  , getEntity
+  , getJust
+  , getJustEntity
+  , getMany
+
+    -- * Selecting by filter
+  , select
+  , selectOne
+  , selectFirst
+  , selectKeys
+  , selectKeysList
+  , selectList
+
+    -- * Selecting counts/existence
+  , count
+  , exists
+
+    -- * Inserting
+  , insertSelect
+  , insertSelectCount
+  , insert
+  , insert_
+  , insertBy
+  , insertEntity
+  , insertEntityMany
+  , insertKey
+  , insertMany
+  , insertMany_
+  , insertRecord
+  , insertUnique
+  , insertUniqueEntity
+
+    -- * Updating
+  , update
+  , updateCount
+  , update'
+  , updateGet
+  , updateWhere
+
+    -- * Insert/update combinations
+  , replace
+  , replaceUnique
+  , repsert
+  , repsertMany
+  , upsert
+  , upsertBy
+  , putMany
+
+    -- * Working with unique constraints
+  , checkUnique
+  , checkUniqueUpdateable
+  , onlyUnique
+
+    -- * Deleting
+  , delete
+  , deleteKey
+  , deleteBy
+  , deleteWhere
+  , deleteCount
+
+    -- * Rendering queries to text
+  , renderQueryDelete
+  , renderQueryInsertInto
+  , renderQuerySelect
+  , renderQueryToText
+  , renderQueryUpdate
+  ) where
+
+#if MIN_VERSION_base(4,17,0)
+import Data.Type.Equality (type (~))
+#endif
+import Database.Persist (Key, PersistEntity (PersistEntityBackend), Update)
+import Database.Persist.Sql.Lifted.Core
+import Database.Persist.Sql.Lifted.Esqueleto
+import Database.Persist.Sql.Lifted.Persistent hiding (delete, update)
+import Database.Persist.Sql.Lifted.Persistent qualified as Persistent
+import GHC.Stack (HasCallStack)
+
+-- | Update individual fields on a specific record
+update'
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> [Update a]
+  -> m ()
+update' = Persistent.update
diff --git a/library/Database/Persist/Sql/Lifted/Core.hs b/library/Database/Persist/Sql/Lifted/Core.hs
new file mode 100644
--- /dev/null
+++ b/library/Database/Persist/Sql/Lifted/Core.hs
@@ -0,0 +1,12 @@
+module Database.Persist.Sql.Lifted.Core
+  ( MonadSqlTx (..)
+  , HasSqlBackend (..)
+  , SqlBackend
+  , MonadSqlBackend (..)
+  , liftSql
+  ) where
+
+import Database.Persist.Sql (SqlBackend)
+import Database.Persist.Sql.Lifted.HasSqlBackend
+import Database.Persist.Sql.Lifted.MonadSqlBackend
+import Database.Persist.Sql.Lifted.MonadSqlTx
diff --git a/library/Database/Persist/Sql/Lifted/Esqueleto.hs b/library/Database/Persist/Sql/Lifted/Esqueleto.hs
new file mode 100644
--- /dev/null
+++ b/library/Database/Persist/Sql/Lifted/Esqueleto.hs
@@ -0,0 +1,202 @@
+{-# LANGUAGE CPP #-}
+
+-- | Wrappers that apply 'liftSql' to Esqueleto utilities of the same name.
+module Database.Persist.Sql.Lifted.Esqueleto
+  ( delete
+  , deleteCount
+  , deleteKey
+  , insertSelect
+  , insertSelectCount
+  , renderQueryDelete
+  , renderQueryInsertInto
+  , renderQuerySelect
+  , renderQueryToText
+  , renderQueryUpdate
+  , select
+  , selectOne
+  , update
+  , updateCount
+  ) where
+
+import Data.Function (($))
+import Data.Int (Int64)
+import Data.Maybe (Maybe)
+import Data.Text (Text)
+#if MIN_VERSION_base(4,17,0)
+import Data.Type.Equality (type (~))
+#endif
+import Database.Esqueleto.Experimental
+  ( Entity
+  , PersistEntity (Key, PersistEntityBackend)
+  , PersistValue
+  , SqlExpr
+  , SqlQuery
+  )
+import Database.Esqueleto.Experimental qualified as E
+import Database.Esqueleto.Internal.Internal (Insertion, Mode, SqlSelect)
+import Database.Persist.Sql.Lifted.Core (MonadSqlBackend, SqlBackend, liftSql)
+import GHC.Stack (HasCallStack)
+
+-- | Execute an Esqueleto DELETE query
+delete :: forall m. (MonadSqlBackend m, HasCallStack) => SqlQuery () -> m ()
+delete q = liftSql $ E.delete q
+
+-- | Execute an Esqueleto DELETE query
+deleteCount
+  :: forall m
+   . (MonadSqlBackend m, HasCallStack)
+  => SqlQuery ()
+  -> m Int64
+  -- ^ The number of rows affected
+deleteCount q = liftSql $ E.deleteCount q
+
+-- | Delete a specific record by identifier
+--
+-- Does nothing if record does not exist.
+deleteKey
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> m ()
+deleteKey k = liftSql $ E.deleteKey k
+
+-- | Insert a 'E.PersistField' for every selected value
+insertSelect
+  :: forall a m
+   . ( PersistEntity a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => SqlQuery (SqlExpr (Insertion a))
+  -> m ()
+insertSelect q = liftSql $ E.insertSelect q
+
+-- | Insert a 'PersistField' for every selected value, returning the count
+insertSelectCount
+  :: forall a m
+   . ( PersistEntity a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => SqlQuery (SqlExpr (Insertion a))
+  -> m Int64
+  -- ^ The number of inserted rows
+insertSelectCount q = liftSql $ E.insertSelectCount q
+
+-- | Renders a 'SqlQuery' to 'Text' along with the list of 'PersistValue's
+--   that would be supplied to the database for @?@ placeholders
+renderQueryDelete
+  :: forall a r m
+   . ( SqlSelect a r
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => SqlQuery a
+  -- ^ SQL query to render
+  -> m (Text, [PersistValue])
+renderQueryDelete q = liftSql $ E.renderQueryDelete q
+
+-- | Renders a 'SqlQuery' to 'Text' along with the list of 'PersistValue's
+--   that would be supplied to the database for @?@ placeholders
+renderQueryInsertInto
+  :: forall a r m
+   . ( SqlSelect a r
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => SqlQuery a
+  -- ^ SQL query to render
+  -> m (Text, [PersistValue])
+renderQueryInsertInto q = liftSql $ E.renderQueryInsertInto q
+
+-- | Renders a 'SqlQuery' to 'Text' along with the list of 'PersistValue's
+--   that would be supplied to the database for @?@ placeholders
+renderQuerySelect
+  :: forall a r m
+   . ( SqlSelect a r
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => SqlQuery a
+  -- ^ SQL query to render
+  -> m (Text, [PersistValue])
+renderQuerySelect q = liftSql $ E.renderQuerySelect q
+
+-- | Renders a 'SqlQuery' to 'Text' along with the list of 'PersistValue's
+--   that would be supplied to the database for @?@ placeholders
+renderQueryToText
+  :: forall a r m
+   . ( SqlSelect a r
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Mode
+  -- ^ Whether to render as an SELECT, DELETE, etc.
+  --   You must ensure that the Mode you pass to this function corresponds
+  --   with the actual SqlQuery. If you pass a query that uses incompatible
+  --   features (like an INSERT statement with a SELECT mode) then you'll
+  --   get a weird result.
+  -> SqlQuery a
+  -- ^ SQL query to render
+  -> m (Text, [PersistValue])
+renderQueryToText m q = liftSql $ E.renderQueryToText m q
+
+-- | Renders a 'SqlQuery' to 'Text' along with the list of 'PersistValue's
+--   that would be supplied to the database for @?@ placeholders
+renderQueryUpdate
+  :: forall a r m
+   . ( SqlSelect a r
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => SqlQuery a
+  -- ^ SQL query to render
+  -> m (Text, [PersistValue])
+renderQueryUpdate q = liftSql $ E.renderQueryUpdate q
+
+-- | Execute an Esqueleto SELECT query
+select
+  :: forall a r m
+   . (SqlSelect a r, MonadSqlBackend m, HasCallStack)
+  => SqlQuery a
+  -> m [r]
+  -- ^ A list of rows
+select q = liftSql $ E.select q
+
+-- | Execute an Esqueleto SELECT query, getting only the first row
+selectOne
+  :: forall a r m
+   . (SqlSelect a r, MonadSqlBackend m, HasCallStack)
+  => SqlQuery a
+  -> m (Maybe r)
+  -- ^ The first row, or 'Nothing' if no rows are selected
+selectOne q = liftSql $ E.selectOne q
+
+-- | Execute an Esqueleto UPDATE query
+update
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => (SqlExpr (Entity a) -> SqlQuery ())
+  -> m ()
+update q = liftSql $ E.update q
+
+-- | Execute an Esqueleto UPDATE query, returning the count
+updateCount
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => (SqlExpr (Entity a) -> SqlQuery ())
+  -> m Int64
+  -- ^ The number of inserted rows
+updateCount q = liftSql $ E.updateCount q
diff --git a/library/Database/Persist/Sql/Lifted/HasSqlBackend.hs b/library/Database/Persist/Sql/Lifted/HasSqlBackend.hs
new file mode 100644
--- /dev/null
+++ b/library/Database/Persist/Sql/Lifted/HasSqlBackend.hs
@@ -0,0 +1,13 @@
+module Database.Persist.Sql.Lifted.HasSqlBackend
+  ( HasSqlBackend (..)
+  ) where
+
+import Prelude
+
+import Database.Persist.Sql (SqlBackend)
+
+class HasSqlBackend a where
+  getSqlBackend :: a -> SqlBackend
+
+instance HasSqlBackend SqlBackend where
+  getSqlBackend = id
diff --git a/library/Database/Persist/Sql/Lifted/MonadSqlBackend.hs b/library/Database/Persist/Sql/Lifted/MonadSqlBackend.hs
new file mode 100644
--- /dev/null
+++ b/library/Database/Persist/Sql/Lifted/MonadSqlBackend.hs
@@ -0,0 +1,25 @@
+module Database.Persist.Sql.Lifted.MonadSqlBackend
+  ( MonadSqlBackend (..)
+  , liftSql
+  ) where
+
+import Prelude
+
+import Control.Exception.Annotated.UnliftIO (checkpointCallStack)
+import Control.Monad.IO.Unlift (MonadUnliftIO)
+import Control.Monad.Reader (ReaderT (..), asks)
+import Database.Persist.Sql (SqlBackend)
+import Database.Persist.Sql.Lifted.HasSqlBackend (HasSqlBackend, getSqlBackend)
+import GHC.Stack (HasCallStack)
+
+-- | A monadic context in which a SQL backend is available
+--   for running database queries
+class MonadUnliftIO m => MonadSqlBackend m where
+  getSqlBackendM :: m SqlBackend
+
+instance (HasSqlBackend r, MonadUnliftIO m) => MonadSqlBackend (ReaderT r m) where
+  getSqlBackendM = asks getSqlBackend
+
+-- | Generalize from 'SqlPersistT' to 'MonadSqlBackend'
+liftSql :: (MonadSqlBackend m, HasCallStack) => ReaderT SqlBackend m a -> m a
+liftSql (ReaderT f) = checkpointCallStack $ getSqlBackendM >>= f
diff --git a/library/Database/Persist/Sql/Lifted/MonadSqlTx.hs b/library/Database/Persist/Sql/Lifted/MonadSqlTx.hs
new file mode 100644
--- /dev/null
+++ b/library/Database/Persist/Sql/Lifted/MonadSqlTx.hs
@@ -0,0 +1,15 @@
+module Database.Persist.Sql.Lifted.MonadSqlTx
+  ( MonadSqlTx (..)
+  ) where
+
+import Control.Monad.IO.Unlift (MonadUnliftIO)
+import Database.Persist.Sql.Lifted.MonadSqlBackend (MonadSqlBackend)
+import GHC.Stack (HasCallStack)
+
+-- | The constraint @'MonadSqlTx' db m@ indicates that @m@ is a monadic
+--   context that can run @db@ actions, usually as a SQL transaction.
+--   Typically, this means that @db@ needs a connection and @m@ can
+--   provide one, e.g. from a connection pool.
+class (MonadSqlBackend db, MonadUnliftIO m) => MonadSqlTx db m | m -> db where
+  -- | Runs the action in a SQL transaction
+  runSqlTx :: HasCallStack => db a -> m a
diff --git a/library/Database/Persist/Sql/Lifted/Persistent.hs b/library/Database/Persist/Sql/Lifted/Persistent.hs
new file mode 100644
--- /dev/null
+++ b/library/Database/Persist/Sql/Lifted/Persistent.hs
@@ -0,0 +1,643 @@
+{-# LANGUAGE CPP #-}
+
+-- | Wrappers that apply 'liftSql' to Persistent utilities of the same name.
+module Database.Persist.Sql.Lifted.Persistent
+  ( checkUnique
+  , checkUniqueUpdateable
+  , count
+  , delete
+  , deleteBy
+  , deleteWhere
+  , exists
+  , get
+  , getBy
+  , getByValue
+  , getEntity
+  , getJust
+  , getJustEntity
+  , getMany
+  , insert
+  , insert_
+  , insertBy
+  , insertEntity
+  , insertEntityMany
+  , insertKey
+  , insertMany
+  , insertMany_
+  , insertRecord
+  , insertUnique
+  , insertUniqueEntity
+  , onlyUnique
+  , putMany
+  , replace
+  , replaceUnique
+  , repsert
+  , repsertMany
+  , selectFirst
+  , selectKeys
+  , selectKeysList
+  , selectList
+  , update
+  , updateGet
+  , updateWhere
+  , upsert
+  , upsertBy
+  ) where
+
+import Conduit (ConduitT, MonadResource, transPipe)
+import Data.Bool (Bool)
+import Data.Either (Either)
+import Data.Eq (Eq)
+import Data.Function (($))
+import Data.Int (Int)
+import Data.Map.Strict (Map)
+import Data.Maybe (Maybe)
+#if MIN_VERSION_base(4,17,0)
+import Data.Type.Equality (type (~))
+#endif
+import Database.Persist
+  ( AtLeastOneUniqueKey
+  , Entity
+  , Filter
+  , OnlyOneUniqueKey
+  , PersistEntity (..)
+  , SelectOpt
+  , Update
+  )
+import Database.Persist.Class qualified as P
+import Database.Persist.Class.PersistEntity (SafeToInsert)
+import Database.Persist.Sql.Lifted.Core (MonadSqlBackend, SqlBackend, liftSql)
+import GHC.Stack (HasCallStack)
+
+-- | Check whether there are any conflicts for unique keys with this entity
+--   and existing entities in the database
+checkUnique
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m (Maybe (Unique a))
+  -- ^ 'Nothing' if the entity would be unique, and could thus safely
+  --   be inserted. On a conflict, 'Just' the conflicting key.
+checkUnique a = liftSql $ P.checkUnique a
+
+-- | Check whether there are any conflicts for unique keys with this entity and existing entities in the database
+--
+-- This is useful for updating because it ignores conflicts when the particular entity already exists.
+checkUniqueUpdateable
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Entity a
+  -> m (Maybe (Unique a))
+  -- ^ 'Nothing' if the entity would stay unique, and could thus safely be updated.
+  --   On a conflict, 'Just' the conflicting key.
+checkUniqueUpdateable e = liftSql $ P.checkUniqueUpdateable e
+
+-- | The total number of records fulfilling the given criteria
+count
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [Filter a]
+  -- ^ If you provide multiple values in the list, the conditions are ANDed together.
+  -> m Int
+count fs = liftSql $ P.count fs
+
+-- | Delete a specific record by identifier
+--
+-- Does nothing if record does not exist.
+delete
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> m ()
+delete k = liftSql $ P.delete k
+
+-- | Delete a specific record by unique key
+--
+-- Does nothing if no record matches.
+deleteBy
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Unique a
+  -> m ()
+deleteBy u = liftSql $ P.deleteBy u
+
+-- | Delete all records matching the given criteria
+deleteWhere
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [Filter a]
+  -- ^ If you provide multiple values in the list, the conditions are ANDed together.
+  -> m ()
+deleteWhere fs = liftSql $ P.deleteWhere fs
+
+-- | Check if there is at least one record fulfilling the given criteria
+exists
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [Filter a]
+  -- ^ If you provide multiple values in the list, the conditions are ANDed together.
+  -> m Bool
+exists fs = liftSql $ P.exists fs
+
+-- | Get a record by identifier, if available
+get
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> m (Maybe a)
+get k = liftSql $ P.get k
+
+-- | Get a record by unique key, if available, returning both the identifier and the record
+getBy
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Unique a
+  -> m (Maybe (Entity a))
+getBy u = liftSql $ P.getBy u
+
+-- Get a record by unique key, if available, returning both the identifier and the record
+--
+-- This function makes the most sense on entities with a single 'Unique' constructor.
+getByValue
+  :: forall a m
+   . ( PersistEntityBackend a ~ SqlBackend
+     , AtLeastOneUniqueKey a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m (Maybe (Entity a))
+  -- ^ A record matching one of the unique keys.
+getByValue a = liftSql $ P.getByValue a
+
+-- | Get a record by identifier, if available
+getEntity
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> m (Maybe (Entity a))
+getEntity k = liftSql $ P.getEntity k
+
+-- | Get a record by identifier, if available, for a non-null (not 'Maybe') foreign key
+--
+-- Unsafe unless your database is enforcing that the foreign key is valid.
+getJust
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> m a
+getJust k = liftSql $ P.getJust k
+
+-- | Get a record by identifier, if available, for a non-null (not 'Maybe') foreign key
+--
+-- Unsafe unless your database is enforcing that the foreign key is valid.
+getJustEntity
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> m (Entity a)
+getJustEntity k = liftSql $ P.getJustEntity k
+
+-- | Get many records by their respective identifiers, if available
+getMany
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [Key a]
+  -> m (Map (Key a) a)
+getMany ks = liftSql $ P.getMany ks
+
+-- | Create a new record in the database
+insert
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m (Key a)
+  -- ^ The auto-increment ID that was generated
+insert a = liftSql $ P.insert a
+
+-- | Create a new record in the database
+insert_
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m ()
+insert_ a = liftSql $ P.insert_ a
+
+-- | Insert a value, checking for conflicts with any unique constraints
+insertBy
+  :: forall a m
+   . ( PersistEntityBackend a ~ SqlBackend
+     , AtLeastOneUniqueKey a
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m (Either (Entity a) (Key a))
+  -- ^ If a duplicate exists in the database, it is returned as 'Left'.
+  --   Otherwise, the new 'Key' is returned as 'Right'.
+insertBy a = liftSql $ P.insertBy a
+
+-- | Create a new record in the database, returning an auto-increment ID and the inserted record
+insertEntity
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m (Entity a)
+insertEntity a = liftSql $ P.insertEntity a
+
+-- | Create multiple records in the database, with specified keys
+insertEntityMany
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [Entity a]
+  -> m ()
+insertEntityMany es = liftSql $ P.insertEntityMany es
+
+-- | Create a new record in the database using the given key
+insertKey
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> a
+  -> m ()
+insertKey k a = liftSql $ P.insertKey k a
+
+-- | Create multiple records in the database and return their 'Key's
+insertMany
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [a]
+  -> m [Key a]
+insertMany as = liftSql $ P.insertMany as
+
+-- | Create multiple records in the database
+insertMany_
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [a]
+  -> m ()
+insertMany_ as = liftSql $ P.insertMany_ as
+
+-- | Create a new record in the database
+insertRecord
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m a
+  -- ^ The record that was inserted
+insertRecord a = liftSql $ P.insertRecord a
+
+-- | Create a new record in the database
+insertUnique
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m (Maybe (Key a))
+  -- ^ An auto-increment ID, or 'Nothing' when the record couldn't be
+  --   inserted because of a uniqueness constraint
+insertUnique a = liftSql $ P.insertUnique a
+
+-- | Create a new record in the database
+insertUniqueEntity
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m (Maybe (Entity a))
+  -- ^ An auto-increment ID and the inserted record, or 'Nothing' when the record
+  --   couldn't be inserted because of a uniqueness constraint.
+insertUniqueEntity a = liftSql $ P.insertUniqueEntity a
+
+-- | Return the single unique key for a record
+onlyUnique
+  :: forall a m
+   . ( PersistEntityBackend a ~ SqlBackend
+     , OnlyOneUniqueKey a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -> m (Unique a)
+onlyUnique a = liftSql $ P.onlyUnique a
+
+-- | Put many records into the database
+--
+-- * Insert new records that do not exist (or violate any unique constraints);
+-- * Replace existing records (matching any unique constraint).
+putMany
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [a]
+  -- ^ A list of the records you want to insert or replace.
+  -> m ()
+putMany as = liftSql $ P.putMany as
+
+-- | Replace the record in the database with the given key
+--
+-- The result is undefined if such record does not exist.
+replace
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> a
+  -> m ()
+replace k a = liftSql $ P.replace k a
+
+-- | Attempt to replace the record of the given key with the given new record
+--
+-- First query the unique fields to make sure the replacement maintains uniqueness constraints.
+replaceUnique
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , Eq (Unique a)
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> a
+  -> m (Maybe (Unique a))
+  -- ^ 'Nothing' if the replacement was made. If uniqueness is violated,
+  --   'Just' the 'Unique' violation.
+replaceUnique k a = liftSql $ P.replaceUnique k a
+
+-- | Put the record in the database with the given key
+--
+-- If a record with the given key does not exist then a new record will be inserted.
+repsert
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> a
+  -> m ()
+repsert k a = liftSql $ P.repsert k a
+
+-- | Put many entities into the database
+--
+-- For each item, if a record with the given key does not exist then a new record will be inserted.
+repsertMany
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [(Key a, a)]
+  -> m ()
+repsertMany kas = liftSql $ P.repsertMany kas
+
+-- | Get just the first record for the criteria
+selectFirst
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [Filter a]
+  -- ^ If you provide multiple values in the list, the conditions are ANDed together.
+  -> [SelectOpt a]
+  -> m (Maybe (Entity a))
+selectFirst fs os = liftSql $ P.selectFirst fs os
+
+-- | Get the 'Key's of all records matching the given criteria
+selectKeys
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , MonadResource m
+     , HasCallStack
+     )
+  => [Filter a]
+  -- ^ If you provide multiple values in the list, the conditions are ANDed together.
+  -> [SelectOpt a]
+  -> ConduitT () (Key a) m ()
+  -- ^ Keys corresponding to the filters and options provided
+selectKeys fs os = transPipe liftSql $ P.selectKeys fs os
+
+-- | Get the 'Key's of all records matching the given criteria
+selectKeysList
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [Filter a]
+  -- ^ If you provide multiple values in the list, the conditions are ANDed together.
+  -> [SelectOpt a]
+  -> m [Key a]
+selectKeysList fs os = liftSql $ P.selectKeysList fs os
+
+selectList
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [Filter a]
+  -- ^ If you provide multiple values in the list, the conditions are ANDed together.
+  -> [SelectOpt a]
+  -> m [Entity a]
+  -- ^ Entities corresponding to the filters and options provided
+selectList fs os = liftSql $ P.selectList fs os
+
+-- | Update individual fields on a specific record
+update
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> [Update a]
+  -> m ()
+update k us = liftSql $ P.update k us
+
+-- | Update individual fields on a specific record, and retrieve the updated value from the database
+--
+-- This function will throw an exception if the given key is not found in the database.
+updateGet
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Key a
+  -> [Update a]
+  -> m a
+updateGet k us = liftSql $ P.updateGet k us
+
+-- | Update individual fields on any record matching the given criteria
+updateWhere
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => [Filter a]
+  -- ^ If you provide multiple values in the list, the conditions are ANDed together.
+  -> [Update a]
+  -> m ()
+updateWhere fs us = liftSql $ P.updateWhere fs us
+
+-- | Update based on a uniqueness constraint or insert:
+--
+-- * Unsert the new record if it does not exist;
+-- * If the record exists (matched via it's uniqueness constraint), then update the
+--   existing record with the parameters which is passed on as list to the function.
+upsert
+  :: forall a m
+   . ( PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , OnlyOneUniqueKey a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => a
+  -- ^ New record to insert
+  -> [Update a]
+  -- ^ Updates to perform if the record already exists
+  -> m (Entity a)
+  -- ^ The record in the database after the operation
+upsert a us = liftSql $ P.upsert a us
+
+-- | Update based on a given uniqueness constraint or insert:
+--
+-- * Insert the new record if it does not exist;
+-- * Update the existing record that matches the given uniqueness constraint.
+upsertBy
+  :: forall a m
+   . ( PersistEntity a
+     , PersistEntityBackend a ~ SqlBackend
+     , SafeToInsert a
+     , MonadSqlBackend m
+     , HasCallStack
+     )
+  => Unique a
+  -- ^ Uniqueness constraint to find by
+  -> a
+  -- ^ New record to insert
+  -> [Update a]
+  -- ^ Updates to perform if the record already exists
+  -> m (Entity a)
+  -- ^ The record in the database after the operation
+upsertBy u a us = liftSql $ P.upsertBy u a us
diff --git a/package.yaml b/package.yaml
new file mode 100644
--- /dev/null
+++ b/package.yaml
@@ -0,0 +1,68 @@
+name: persistent-sql-lifted
+version: 0.1.0.0
+
+maintainer: Freckle Education
+category: Database
+github: freckle/freckle-app
+synopsis: Monad classes for running queries with Persistent and Esqueleto
+description: |
+  This package introduces two classes: MonadSqlBackend for monadic contexts in
+  which a SqlBackend is available, and MonadSqlBackend for contexts in which we
+  can execute a SQL transaction.
+
+  Additionally, this package provides variants of query-running utilities from
+  Persistent and Esqueleto which are concretized to use SqlBackend, generalized
+  to a MonadSqlBackend m constraint rather than "ReaderT backend", and wrapped in
+  checkpointCallStack so that exceptions will include call stacks.
+
+extra-doc-files:
+  - README.md
+  - CHANGELOG.md
+
+extra-source-files:
+  - package.yaml
+
+language: GHC2021
+
+ghc-options:
+  - -fignore-optim-changes
+  - -fwrite-ide-info
+  - -Weverything
+  - -Wno-all-missed-specialisations
+  - -Wno-missing-exported-signatures # re-enables missing-signatures
+  - -Wno-missing-import-lists
+  - -Wno-missing-kind-signatures
+  - -Wno-missing-local-signatures
+  - -Wno-missing-safe-haskell-mode
+  - -Wno-monomorphism-restriction
+  - -Wno-prepositive-qualified-module
+  - -Wno-safe
+  - -Wno-unsafe
+
+when:
+  - condition: "impl(ghc >= 9.8)"
+    ghc-options:
+      - -Wno-missing-role-annotations
+      - -Wno-missing-poly-kind-signatures
+
+dependencies:
+  - base < 5
+
+default-extensions:
+  - ExplicitNamespaces
+  - FunctionalDependencies
+  - GADTs
+  - NoImplicitPrelude
+  - NoMonomorphismRestriction
+
+library:
+  source-dirs: library
+  dependencies:
+    - annotated-exception
+    - conduit
+    - containers
+    - esqueleto
+    - mtl
+    - persistent
+    - text
+    - unliftio-core
diff --git a/persistent-sql-lifted.cabal b/persistent-sql-lifted.cabal
new file mode 100644
--- /dev/null
+++ b/persistent-sql-lifted.cabal
@@ -0,0 +1,71 @@
+cabal-version:      1.18
+name:               persistent-sql-lifted
+version:            0.1.0.0
+license:            MIT
+license-file:       LICENSE
+maintainer:         Freckle Education
+homepage:           https://github.com/freckle/freckle-app#readme
+bug-reports:        https://github.com/freckle/freckle-app/issues
+synopsis:
+    Monad classes for running queries with Persistent and Esqueleto
+
+description:
+    This package introduces two classes: MonadSqlBackend for monadic contexts in
+    which a SqlBackend is available, and MonadSqlBackend for contexts in which we
+    can execute a SQL transaction.
+    .
+    Additionally, this package provides variants of query-running utilities from
+    Persistent and Esqueleto which are concretized to use SqlBackend, generalized
+    to a MonadSqlBackend m constraint rather than "ReaderT backend", and wrapped in
+    checkpointCallStack so that exceptions will include call stacks.
+
+category:           Database
+build-type:         Simple
+extra-source-files: package.yaml
+extra-doc-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+    type:     git
+    location: https://github.com/freckle/freckle-app
+
+library
+    exposed-modules:
+        Database.Persist.Sql.Lifted
+        Database.Persist.Sql.Lifted.Core
+        Database.Persist.Sql.Lifted.Esqueleto
+        Database.Persist.Sql.Lifted.HasSqlBackend
+        Database.Persist.Sql.Lifted.MonadSqlBackend
+        Database.Persist.Sql.Lifted.MonadSqlTx
+        Database.Persist.Sql.Lifted.Persistent
+
+    hs-source-dirs:     library
+    other-modules:      Paths_persistent_sql_lifted
+    default-language:   GHC2021
+    default-extensions:
+        ExplicitNamespaces FunctionalDependencies GADTs NoImplicitPrelude
+        NoMonomorphismRestriction
+
+    ghc-options:
+        -fignore-optim-changes -fwrite-ide-info -Weverything
+        -Wno-all-missed-specialisations -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-kind-signatures
+        -Wno-missing-local-signatures -Wno-missing-safe-haskell-mode
+        -Wno-monomorphism-restriction -Wno-prepositive-qualified-module
+        -Wno-safe -Wno-unsafe
+
+    build-depends:
+        annotated-exception >=0.2.0.4,
+        base >=4.16.4.0 && <5,
+        conduit >=1.3.5,
+        containers >=0.6.5.1,
+        esqueleto >=3.5.10.0,
+        mtl >=2.2.2,
+        persistent >=2.14.0.1,
+        text >=1.2.5.0,
+        unliftio-core >=0.2.1.0
+
+    if impl(ghc >=9.8)
+        ghc-options:
+            -Wno-missing-role-annotations -Wno-missing-poly-kind-signatures
