diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,18 @@
 # Changelog for pg-schema
 
+## 0.7.1.0
+- Condition DSL: `(=??)` on nullable columns generates SQL
+  `IS NOT DISTINCT FROM` (same null-safe key equality as upsertJSON)
+- Improve transaction behavior for insertJSON/upsertJSON
+- Add tests for insertJSON/upsertJSON with transactions
+- `upsertJSON` / `upsertJSON_`: optional `ON CONFLICT` targets include NOT NULL
+  unique keys (primary key still first), then nullable unique keys when the table
+  has no NOT NULL UK; `UPDATE` by key uses `IS NOT DISTINCT FROM` for key columns;
+  `IdentityCandidates` (compile-time) lists every PK/UK from the schema
+- `insertJSON` / `insertJSON_`: insert-only (plain `INSERT`, no `ON CONFLICT` or
+  `UPDATE`; duplicate keys fail at the database)
+- Rename `AllMandatoryOrHasPKTree` to `AllMandatoryOrHasKeyTree`
+
 ## 0.7.0.1
 - Bug fixing (upsertJSON_ without change fields: on conflict do nothing)
 
diff --git a/pg-schema.cabal b/pg-schema.cabal
--- a/pg-schema.cabal
+++ b/pg-schema.cabal
@@ -1,6 +1,6 @@
 cabal-version:  3.12
 name:           pg-schema
-version:        0.7.0.1
+version:        0.7.1.0
 category:       Database
 author:         Dmitry Olshansky
 maintainer:     olshanskydr@gmail.com
@@ -41,7 +41,7 @@
     data Order = Order { num :: Text, createdAt :: Day, items :: [OrdPos] } deriving Generic
     data OrdPos = OrdPos { id :: Int32, num :: Int32, article :: Article, price :: Double } deriving Generic
     data Article = Article { id :: Int32, name :: Text } deriving Generic
-    type MyAnn tabName = 'Ann 5 CamelToSnake MySch ("dbSchema" ->> tabName)
+    type MyAnn tabName = 'Ann CamelToSnake MySch 5 ("dbSchema" ->> tabName)
         ...
     do
       -- insert an order with order positions
@@ -155,6 +155,7 @@
     , directory >= 1.3 && < 1.5
     , exceptions >= 0.9 && < 0.11
     , mtl >= 2.0 && < 2.4
+    , postgresql-libpq >= 0.11 && < 0.12
     , postgresql-simple >= 0.6 && < 0.8
     , scientific >= 0.2 && < 0.4
     , singletons >= 3.0.3 && < 3.1
@@ -227,6 +228,7 @@
     , aeson
     , bytestring
     , pg-schema
+    , postgresql-libpq >= 0.11 && < 0.12
     , postgresql-simple >= 0.6 && < 0.8
     , text
   default-language: GHC2021
@@ -240,6 +242,7 @@
   ghc-options: -Wall
 
 test-suite test-gen
+  type:             exitcode-stdio-1.0
   main-is:          Main.hs
   other-modules:
     -- Paths and PackageInfo
@@ -253,6 +256,7 @@
     , bytestring >= 0.12.2 && < 0.13
     , directory >= 1.3.9 && < 1.4
     , pg-schema
+    , postgresql-libpq >= 0.11 && < 0.12
     , postgresql-simple >= 0.6 && < 0.8
   hs-source-dirs:   test-gen
   default-language: GHC2021
@@ -266,6 +270,8 @@
     Tests.BaseConverts
     Tests.Conditions
     Tests.Hierarchy
+    Tests.InsertJSONTransaction
+    Tests.UpsertUniqueKey
     Tests.Path
     Utils
     -- Paths and PackageInfo
diff --git a/src/PgSchema/Ann.hs b/src/PgSchema/Ann.hs
--- a/src/PgSchema/Ann.hs
+++ b/src/PgSchema/Ann.hs
@@ -357,10 +357,10 @@
   => FromRow (PgTag ann r) where
     fromRow = PgTag <$> fromRowCols @ann @colsCase @cols
 
--- >>> type AnnRel = 'Ann RenamerId PgCatalog (PGC "pg_constraint")
+-- >>> type AnnRel = 'Ann RenamerId PgCatalog 1 (PGC "pg_constraint")
 -- >>> (r1 :: [PgTag AnnRel ( ("conkey" := Int16))]) <- query_ conn "select 1::int2"
 -- >>> r1
--- [PgTag {unPgTag = PgTag {unPgTag = 1}}]
+-- [PgTag {unPgTag = "conkey" =: 1}]
 
 class ToRowCols (ann :: Ann) (colsCase :: ColsCase) (cols :: [ColInfo NameNSK]) r
   where
@@ -644,7 +644,7 @@
       :$$: Text "" )
   CheckRef ann fldDbName b b = ()
 --------------------------------------------------------------------------------
--- Node-level checks for Mandatory / PK (analogue of CheckNodeAll*)
+-- Node-level checks for Mandatory / upsert keys (CheckNodeAll* analogue)
 --------------------------------------------------------------------------------
 
 -- | One-table check that all mandatory fields are present
@@ -660,23 +660,34 @@
     :$$: Text "Table: " :<>: ShowType tab
     :$$: Text "Missing mandatory fields: " :<>: ShowType rest )
 
--- | One-table check that all mandatory fields are present
--- or all PK fields are present
-type family CheckAllMandatoryOrHasPK (ann :: Ann) (rs :: [Symbol]) :: Constraint where
-  CheckAllMandatoryOrHasPK ('Ann ren sch d tab) rs = CheckAllMandatoryOrHasPK'
-    (RestMandatory sch tab rs) (RestPK sch tab rs) ('Ann ren sch d tab) rs
+-- | All mandatory fields in @rs@, or a full primary key, or a full eligible
+-- unique key (see 'IdentityCandidates').
+type family CheckAllMandatoryOrHasKey (ann :: Ann) (rs :: [Symbol]) :: Constraint where
+  CheckAllMandatoryOrHasKey ('Ann ren sch d tab) rs = CheckAllMandatoryOrHasKey'
+    (RestMandatory sch tab rs) (IdentityCandidates sch tab)
+    ('Ann ren sch d tab) rs
 
-type family CheckAllMandatoryOrHasPK' (restMandatory :: [Symbol]) (restPK :: [Symbol]) (ann :: Ann) (rs :: [Symbol]) :: Constraint where
-  CheckAllMandatoryOrHasPK' '[] rpk ann rs = ()
-  CheckAllMandatoryOrHasPK' rm '[] ann rs = ()
-  CheckAllMandatoryOrHasPK' rm rpk ('Ann ren sch d tab) rs = TypeError
-    (  Text "We can't upsert data because for table " :<>: ShowType tab
-    :$$: Text "either not all mandatory fields or not all PK fields are present."
-    :$$: Text "Missing mandatory fields: " :<>: ShowType rm
-    :$$: Text "Missing PK fields: " :<>: ShowType rpk )
+type family CheckAllMandatoryOrHasKey' (restMandatory :: [Symbol]) (cands :: [[Symbol]]) (ann :: Ann) (rs :: [Symbol]) :: Constraint where
+  CheckAllMandatoryOrHasKey' '[] _ ann rs = ()
+  CheckAllMandatoryOrHasKey' rm cands ann rs = HasAnyFullIdentity rs cands ann
 
-genDefunSymbols [ ''CheckAllMandatory, ''CheckAllMandatoryOrHasPK]
+type family HasAnyFullIdentity (rs :: [Symbol]) (cands :: [[Symbol]]) ann :: Constraint where
+  HasAnyFullIdentity rs '[] ('Ann _ sch _ tab) =
+    TypeError
+      (  Text "We can't upsert data because for table " :<>: ShowType tab
+      :$$: Text "not all mandatory fields are present and no full primary or"
+      :$$: Text "unique key is covered by record fields."
+      :$$: Text "Missing mandatory fields: " :<>: ShowType (RestMandatory sch tab rs)
+      :$$: Text "Possible keys: " :<>: ShowType (IdentityCandidates sch tab) )
+  HasAnyFullIdentity rs (k ': ks) ann =
+    HasAnyFullIdentity' (RestKey rs k) rs ks ann
 
+type family HasAnyFullIdentity' (restKey :: [Symbol]) (rs :: [Symbol]) (ks :: [[Symbol]]) ann :: Constraint where
+  HasAnyFullIdentity' '[] rs ks ann = ()
+  HasAnyFullIdentity' (_ ': _) rs ks ann = HasAnyFullIdentity rs ks ann
+
+genDefunSymbols [ ''CheckAllMandatory, ''CheckAllMandatoryOrHasKey]
+
 --------------------------------------------------------------------------------
 -- Recursive AllMandatory / PK for tree (JSON insert / upsert)
 --------------------------------------------------------------------------------
@@ -698,11 +709,11 @@
   AllMandatoryTree ann r rFlds =
     WalkLevelAnn CheckAllMandatorySym0 ann (TRecordInfo ann r) rFlds
 
-type family AllMandatoryOrHasPKTree (ann :: Ann) (r :: Type) (rFlds :: [Symbol])
+type family AllMandatoryOrHasKeyTree (ann :: Ann) (r :: Type) (rFlds :: [Symbol])
   :: Constraint where
-  AllMandatoryOrHasPKTree ann [r] rFlds = AllMandatoryOrHasPKTree ann r rFlds
-  AllMandatoryOrHasPKTree ann r rFlds =
-    WalkLevelAnn CheckAllMandatoryOrHasPKSym0 ann (TRecordInfo ann r) rFlds
+  AllMandatoryOrHasKeyTree ann [r] rFlds = AllMandatoryOrHasKeyTree ann r rFlds
+  AllMandatoryOrHasKeyTree ann r rFlds =
+    WalkLevelAnn CheckAllMandatoryOrHasKeySym0 ann (TRecordInfo ann r) rFlds
 
 --------------------------------------------------------------------------------
 -- Returning tree must be subtree of input tree (with path)
diff --git a/src/PgSchema/DML.hs b/src/PgSchema/DML.hs
--- a/src/PgSchema/DML.hs
+++ b/src/PgSchema/DML.hs
@@ -16,7 +16,7 @@
 -- data Order = Order { num :: Text, createdAt :: Day, items :: [OrdPos] } deriving Generic
 -- data OrdPos = OrdPos { id :: Int32, num :: Int32, article :: Article, price :: Double } deriving Generic
 -- data Article = Article { id :: Int32, name :: Text } deriving Generic
--- type MyAnn tabName = 'Ann 5 CamelToSnake MySch ("dbSchema" ->> tabName)
+-- type MyAnn tabName = 'Ann CamelToSnake MySch 5 ("dbSchema" ->> tabName)
 --     ...
 -- do
 --   void $ insertJSON_ (MyAnn "orders") conn
@@ -43,10 +43,10 @@
 -- JSON internally. Child data is carried in list fields: the field’s name (after 'Renamer') names
 -- the FK constraint in the database and thus selects the child table and link;
 -- each list element supplies one child row’s columns, with nested lists for further
--- children in the same way. For strict inserts,
--- 'insertJSON' and 'insertJSON_' require every mandatory column at each node;
--- 'upsertJSON' / 'upsertJSON_' relax that so each row can be resolved by keys and
--- optional columns (see their Haddock). Plain 'insertSch' / 'insertSch_' follow the
+-- children in the same way. 'insertJSON' / 'insertJSON_' require every mandatory
+-- column and emit plain @INSERT@ only (duplicates fail in the database).
+-- 'upsertJSON' / 'upsertJSON_' relax mandatory requirements and may @UPDATE@ or
+-- @INSERT … ON CONFLICT …@ (see their Haddock). Plain 'insertSch' / 'insertSch_' follow the
 -- same safety story for flat (non-tree) rows.
 --
 -- 'selectSch' decodes each row into a Haskell type @r@ whose fields describe the
@@ -82,6 +82,9 @@
   -- *** Conditions
   -- | Example: @"name" =? "John"@
   , (<?),(>?),(<=?),(>=?),(=?)
+  -- | Null-safe equality on nullable columns (@IS NOT DISTINCT FROM@).
+  -- | Example: @"suffix" =?? (Just "a")@
+  , (=??)
   -- | Example: @"name" ~~? "%joh%"@
   ,(~=?),(~~?)
   , (|||), (&&&), pnot, pnull, pin, pinArr
@@ -101,7 +104,7 @@
   , UpdateReturning, UpdateNonReturning, CRecInfo(..)
   -- ** Tree-base Insert/Upsert
   , insertJSON, insertJSON_, upsertJSON, upsertJSON_, insertJSONText, insertJSONText_
-  , TreeIn, TreeOut, AllMandatoryTree, AllMandatoryOrHasPKTree, TreeSch
+  , TreeIn, TreeOut, AllMandatoryTree, AllMandatoryOrHasKeyTree, TreeSch
   , InsertTreeNonReturning, InsertTreeReturning
   , UpsertTreeNonReturning, UpsertTreeReturning, TRecordInfo
   -- ** Delete
diff --git a/src/PgSchema/DML/Insert/Types.hs b/src/PgSchema/DML/Insert/Types.hs
--- a/src/PgSchema/DML/Insert/Types.hs
+++ b/src/PgSchema/DML/Insert/Types.hs
@@ -60,14 +60,16 @@
 
 -- | Upsert tree without @RETURNING@.
 --
--- Check that all mandatory fields or primary keys are present in all tables in tree.
+-- Check that all mandatory fields or a full primary / eligible unique key are
+-- present at each tree node.
 -- Reference fields in the child tables are not checked - they are inserted automatically.
 type UpsertTreeNonReturning ann r =
-  (TreeSch ann, TreeIn ann r, AllMandatoryOrHasPKTree ann r '[])
+  (TreeSch ann, TreeIn ann r, AllMandatoryOrHasKeyTree ann r '[])
 
 -- | Upsert tree with @RETURNING@.
 --
--- Check that all mandatory fields or primary keys are present in all tables in tree.
+-- Check that all mandatory fields or a full primary / eligible unique key are
+-- present at each tree node.
 -- Reference fields in the child tables are not checked - they are inserted automatically.
 --
 -- It also checks that we get returnings only from the tables we upserted into.
diff --git a/src/PgSchema/DML/InsertJSON.hs b/src/PgSchema/DML/InsertJSON.hs
--- a/src/PgSchema/DML/InsertJSON.hs
+++ b/src/PgSchema/DML/InsertJSON.hs
@@ -18,30 +18,35 @@
 import Data.Traversable
 import PgSchema.Ann
 import PgSchema.DML.Insert.Types
+import Database.PostgreSQL.LibPQ qualified as PQ
 import Database.PostgreSQL.Simple
+import Database.PostgreSQL.Simple.Internal (withConnection)
 import PgSchema.Schema
 import PgSchema.Types
 import Data.String
-import GHC.Int
 import PgSchema.Utils.Internal
 import Prelude as P
 
 
 -- | Insert records into a table and its children using JSON data internally.
 --
--- Like 'upsertJSON', but requires all mandatory columns at every node (insert-only constraint).
+-- Uses the same SQL pipeline as 'upsertJSON', but each row is a plain @INSERT@
+-- (no @UPDATE@, no @INSERT … ON CONFLICT …@). Any duplicate primary or unique key
+-- raises a PostgreSQL error.
+--
+-- Requires all mandatory columns at every node (see 'InsertTreeReturning').
 
 insertJSON
   :: forall ann -> forall r r'. InsertTreeReturning ann r r'
   => Connection -> [r] -> IO ([r'], Text)
-insertJSON ann @r @r' = insertJSONImpl ann @r @r'
+insertJSON ann @r @r' conn rs = insertJSONImpl ann @r @r' conn rs True
 
 -- | Like 'insertJSON', but does not return rows.
 --
 insertJSON_
   :: forall ann -> forall r. InsertTreeNonReturning ann r
   => Connection -> [r] -> IO Text
-insertJSON_ ann @r = insertJSONImpl_ ann @r
+insertJSON_ ann @r conn rs = insertJSONImpl_ ann @r conn rs True
 
 -- | Upsert a forest of rows into the root table and its /child/ tables in one
 -- round-trip, using JSON inside PostgreSQL (same pipeline as 'insertJSON').
@@ -59,37 +64,41 @@
 --
 -- __What to supply at each node:__ at every level, each row must either include
 -- all mandatory columns (for columns that are mandatory in the schema /sense of
--- this API/) or, alternatively, enough primary-key columns to identify an existing
--- row. Foreign-key columns that are filled in by the parent level (for example
+-- this API/) or, alternatively, enough columns to identify an existing row via
+-- the primary key or a unique constraint (including constraints with nullable
+-- columns when the table has no @NOT NULL@ unique constraint besides PK).
+-- If a nullable key column is @null@ in the payload, conflict/update semantics
+-- follow PostgreSQL (see haddock on 'upsertJSON').
+-- Foreign-key columns that are filled in by the parent level (for example
 -- after an auto-generated id on insert) do /not/ need to be present on the child
 -- payload.
 --
 -- __Insert vs update vs upsert per row:__ the engine picks one of @INSERT@,
 -- @UPDATE@, or @UPSERT@ from the keys and mandatory fields you provide:
 --
--- * all mandatory fields present and /no/ primary key  →  @INSERT@
--- * primary key present, not all mandatory fields      →  @UPDATE@
--- * primary key present /and/ all mandatory fields    →  @UPSERT@
+-- * all mandatory fields present and no full identity key  →  @INSERT@
+-- * identity (PK or first eligible unique whose columns are all in the node), not all mandatory  →  @UPDATE@
+-- * identity present /and/ all mandatory fields  →  @UPSERT@ (@INSERT … ON CONFLICT …@)
 --
--- 'insertJSON' is the same execution path but adds a stricter type-level
--- constraint: /every/ mandatory field must be present (pure inserts). 'upsertJSON'
--- relaxes that so updates and upserts are expressible as in the rules above.
+-- 'insertJSON' shares this execution path but is insert-only at runtime and
+-- requires /every/ mandatory field at compile time. 'upsertJSON' relaxes both.
 upsertJSON
   :: forall ann -> forall r r'. UpsertTreeReturning ann r r'
   => Connection -> [r] -> IO ([r'], Text)
-upsertJSON ann @r @r' = insertJSONImpl ann @r @r'
+upsertJSON ann @r @r' conn rs = insertJSONImpl ann @r @r' conn rs False
 
 -- | Like 'upsertJSON', but does not return rows.
 --
 upsertJSON_
   :: forall ann -> forall r. UpsertTreeNonReturning ann r
   => Connection -> [r] -> IO Text
-upsertJSON_ ann @r = insertJSONImpl_ ann @r
+upsertJSON_ ann @r conn rs = insertJSONImpl_ ann @r conn rs False
 
 insertJSONImpl
   :: forall ann -> forall r r'. (TreeSch ann, TreeIn ann r, TreeOut ann r')
-  => Connection -> [r] -> IO ([r'], Text)
-insertJSONImpl ann @r @r' conn rs = withTransactionIfNot conn do
+  => Connection -> [r] -> Bool -> IO ([r'], Text)
+  -- ^ @Bool@ is @isInsertOnly@.
+insertJSONImpl ann @r @r' conn rs isInsertOnly = withTransactionIfNot conn do
   let sql' = T.unpack sql in trace' sql' $ void $ execute_ conn $ fromString sql'
   [Only res] <- let q = "select pg_temp.__ins(?)" in
     traceShow' q
@@ -98,40 +107,54 @@
   void $ execute_ conn "drop function pg_temp.__ins"
   pure (unPgTag @ann @r' <$> res, sql)
   where
-    sql = insertJSONText ann @r @r'
+    sql = insertJSONText' isInsertOnly (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))
+      (getRecordInfo @ann @r) (getRecordInfo @ann @r').fields
 
 withTransactionIfNot :: Connection -> IO a -> IO a
 withTransactionIfNot conn act = do
-  isInTrans <- any (isJust @Int64 . fromOnly)
-    <$> query_ conn "SELECT txid_current_if_assigned()"
-  (if isInTrans then id else withTransaction conn) act
+  stat <- withConnection conn PQ.transactionStatus
+  case stat of
+    PQ.TransIdle -> withTransaction conn act
+    PQ.TransInTrans -> act
+    PQ.TransInError -> act
+    PQ.TransActive ->
+      error "PgSchema.DML.InsertJSON.withTransactionIfNot: connection is active"
+    PQ.TransUnknown ->
+      error "PgSchema.DML.InsertJSON.withTransactionIfNot: \
+        \unknown transaction status"
 
 insertJSONImpl_
   :: forall ann -> forall r. (TreeSch ann, TreeIn ann r)
-  => Connection -> [r] -> IO Text
-insertJSONImpl_ ann @r conn rs = withTransactionIfNot conn do
+  => Connection -> [r] -> Bool -> IO Text
+insertJSONImpl_ ann @r conn rs isInsertOnly = withTransactionIfNot conn do
   void $ trace' (T.unpack sql) $ execute_ conn $ fromString $ T.unpack sql
   void $ execute conn "call pg_temp.__ins(?)" $ Only $ PgTag @ann @r <$> rs
   sql <$ execute_ conn "drop procedure pg_temp.__ins"
   where
-    sql = insertJSONText_ ann @r
+    sql = insertJSONText' isInsertOnly (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))
+      (getRecordInfo @ann @r) []
 
 insertJSONText_ :: forall ann -> forall r s.
   (IsString s, Monoid s, Ord s, TreeSch ann, CRecInfo ann r) => s
-insertJSONText_ ann @r = insertJSONText' (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))
-  (getRecordInfo @ann @r) []
+insertJSONText_ ann @r =
+  insertJSONText' True (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))
+    (getRecordInfo @ann @r) []
 
 insertJSONText :: forall ann -> forall r r'.
   ( TreeSch ann, CRecInfo ann r, CRecInfo ann r'
   , IsString s, Monoid s, Ord s ) => s
 insertJSONText ann @r @r' =
-  insertJSONText' (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))
+  insertJSONText' True (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))
     (getRecordInfo @ann @r) (getRecordInfo @ann @r').fields
 
+-- | @isInsertOnly@: plain @INSERT@ only ('insertJSON'); 'False' enables upsert
+-- key resolution ('upsertJSON').
 insertJSONText'
   :: forall s. (IsString s, Monoid s, Ord s)
-  => M.Map NameNS TypDef -> M.Map NameNS TabInfo -> RecordInfo Text -> [FieldInfo Text] -> s
-insertJSONText' mapTypes mapTabs ir qfs = unlines'
+  => Bool
+  -> M.Map NameNS TypDef -> M.Map NameNS TabInfo -> RecordInfo Text
+  -> [FieldInfo Text] -> s
+insertJSONText' isInsertOnly mapTypes mapTabs ir qfs = unlines'
   [ maybe
     "create or replace procedure pg_temp.__ins(data_0 jsonb) as $$"
     (const "create or replace function pg_temp.__ins(data_0 jsonb) returns jsonb as $$")
@@ -145,7 +168,8 @@
   , "$$ language plpgsql;" ]
   where
     (mbRes, (decl, body)) =
-      evalRWS (insertJSONTextM mapTypes mapTabs ir qfs [] []) ("  ",0) 0
+      evalRWS (insertJSONTextM isInsertOnly mapTypes mapTabs ir qfs [] [])
+        ("  ",0) 0
 
 type MonadInsert s = RWS (s, Int) ([s],[s]) Int
 -- R: (leading spaces to format code, number of table in tree)
@@ -160,11 +184,26 @@
   , info :: v }
   deriving (Eq, Show)
 
+-- | Runtime conflict/update targets: PK, then @NOT NULL@ unique constraints,
+-- then nullable unique constraints only when the table has no @NOT NULL@ UK.
+-- Compile-time checks use all keys from 'IdentityCandidates'.
+identityCandidatesFromTab :: TabInfo -> [[Text]]
+identityCandidatesFromTab ti =
+  P.filter (not . P.null) [ti.tiDef.tdKey] <> notNullUks <> nullUks
+  where
+    isNullable = (== Just True) . fmap (.fdNullable) . (`M.lookup` ti.tiFlds)
+    (nullUks, notNullUks) = L.partition (P.any isNullable) ti.tiDef.tdUniq
+
+mandatoryDbNames :: TabInfo -> [Text]
+mandatoryDbNames ti =
+  M.keys $ M.filter (\fd -> not $ fd.fdNullable || fd.fdHasDefault) ti.tiFlds
+
 insertJSONTextM
   :: forall s. (IsString s, Monoid s, Ord s)
-  => M.Map NameNS TypDef -> M.Map NameNS TabInfo -> RecordInfo Text
+  => Bool
+  -> M.Map NameNS TypDef -> M.Map NameNS TabInfo -> RecordInfo Text
   -> [FieldInfo Text] -> [s] -> [s] -> MonadInsert s (Maybe s)
-insertJSONTextM mapTypes mapTabs ri qfs fromFields toVars = do
+insertJSONTextM isInsertOnly mapTypes mapTabs ri qfs fromFields toVars = do
   (spaces, n) <- ask
   let
     sn = show' n
@@ -179,13 +218,21 @@
     startLoop =
       ["for " <> rowN <> " in select * from jsonb_array_elements("
       <> dataN <> ")", "loop"]
-    (ins, op) = case mbKeyMand of
-      Just (pk, mflds)
-        | not $ P.null $ mflds L.\\ (fromFields <> (fst <$> plains)) ->
-          (addSemiColon (upd0 <> rets), UPD)
-        | P.null $ pk L.\\ (fromFields <> (fst <$> plainsPK)) -> (ups0 pk, UPS)
-      _ -> (addSemiColon (ins0 <> rets), INS)
+    (ins, op) = resolve
       where
+        jsonFld ip = case mapTypes M.!? ip.info.fdType of
+          Just (TypDef "A" (Just t) _) ->
+            "case when jsonb_typeof(" <> rowN <> ".value->'" <> fld <> "') = 'array'"
+            <> " then (select coalesce(array_agg(__x)::" <> fromText (qualName t) <> "[], '{}')"
+            <> " from jsonb_array_elements_text(" <> rowN <> ".value->'" <> fld <> "') __x) else null end"
+          _
+            | tn == "json" || tn == "jsonb" -> "(" <> rowN <> ".value->'" <> fld <> "')"
+            | otherwise                     -> "(" <> rowN <> ".value->>'" <> fld <> "')::" <> ty
+          where
+            fld = fromText ip.jsonName
+            ty  = fromText (qualName ip.info.fdType)
+            tn  = ip.info.fdType.nnsName
+        plains = iplains <&> \ip -> (fromText ip.dbName, jsonFld ip)
         srcFlds = fromFields <> (fst <$> plains)
         srcVars = toVars <> (jsonFld <$> iplains)
         srcMap = M.fromList $ P.zip srcFlds srcVars
@@ -198,13 +245,27 @@
         ins0 =
           [ "  insert into " <> qualTabName <> "(" <> intercalate' ", " srcFlds <> ")"
           , "    values (" <> intercalate' ", " srcVars <> ")"]
+        mbTab = mapTabs M.!? ri.tabName
+        mflds = foldMap (fmap fromText . mandatoryDbNames) mbTab
+        keyNames
+          | isInsertOnly = []
+          | otherwise = F.fold $ L.find (P.null . (L.\\ srcFlds))
+              $ fmap fromText <$> foldMap identityCandidatesFromTab mbTab
+        (plainsKey, plainsOthers) =
+          L.partition ((`L.elem` keyNames) . fst) plains
         sWhere = "where " <> intercalate' " and "
-            ( L.zipWith nameVal fromFields toVars <> (uncurry nameVal <$> plainsPK))
+            ( L.zipWith nameVal fromFields toVars
+            <> (uncurry keyValDistinct <$> plainsKey))
         upd0 =
           [ "  update " <> qualTabName
           , "    set " <> intercalate' ", " (uncurry nameVal <$> plains)
           , "    " <> sWhere]
-        ups0 pk
+        qretVars = (<> sn) <$> qretFlds
+        rets
+          | noRets = []
+          | otherwise = ["    returning " <> intercalate' ", " qretFlds
+            <> " into " <> intercalate' ", " qretVars]
+        ups0 conflictCols
           | L.null plainsOthers = case mbSetVars of
             Just xs -> ins0 <> [ "    on conflict do nothing;"]
               <> ["  " <> intercalate' " " xs]
@@ -214,31 +275,17 @@
                 , "      from " <> qualTabName
                 , "      " <> sWhere <> ";"
                 , "  end if;"]
-          | L.null pk = addSemiColon ins0 -- support for tables without PK
+          | P.null conflictCols = addSemiColon ins0
           | otherwise = addSemiColon $ ins0
-            <> [ "    on conflict (" <> intercalate' ", " pk <> ")"
+            <> [ "    on conflict (" <> intercalate' ", " conflictCols <> ")"
               , "      do update set " <> intercalate' ", " (plainsOthers <&>
                   \(name, _) -> name <> " = " <> "EXCLUDED." <> name) ]
             <> rets
-        qretVars = (<> sn) <$> qretFlds
-        plains = iplains <&> \ip -> (fromText ip.dbName, jsonFld ip)
-        (plainsPK, plainsOthers) = L.partition ((`L.elem` foldMap fst mbKeyMand) . fst) plains
-        jsonFld ip = case mapTypes M.!? ip.info.fdType of
-          Just (TypDef "A" (Just t) _) ->
-            "case when jsonb_typeof(" <> rowN <> ".value->'" <> fld <> "') = 'array'"
-            <> " then (select coalesce(array_agg(__x)::" <> fromText (qualName t) <> "[], '{}')"
-            <> " from jsonb_array_elements_text(" <> rowN <> ".value->'" <> fld <> "') __x) else null end"
-          _
-            | tn == "json" || tn == "jsonb" -> "(" <> rowN <> ".value->'" <> fld <> "')"
-            | otherwise                     -> "(" <> rowN <> ".value->>'" <> fld <> "')::" <> ty
-          where
-            fld = fromText ip.jsonName
-            ty  = fromText (qualName ip.info.fdType)
-            tn  = ip.info.fdType.nnsName
-        rets
-          | noRets = []
-          | otherwise = ["    returning " <> intercalate' ", " qretFlds
-            <> " into " <> intercalate' ", " qretVars]
+        resolve
+          | not isInsertOnly, not $ P.null $ mflds L.\\ srcFlds =
+            (addSemiColon (upd0 <> rets), UPD)
+          | not isInsertOnly, not $ P.null keyNames = (ups0 keyNames, UPS)
+          | otherwise = (addSemiColon (ins0 <> rets), INS)
     endLoop = "end loop;"
     processChildren = do
       (spaces', _) <- ask
@@ -250,7 +297,8 @@
         let
           qfs' = foldMap ((.fields) . snd)
             $ L.find (\(qc, _) -> qc.jsonName == child.jsonName) qchildren
-        mbArr <- local (second $ const n') $ insertJSONTextM mapTypes mapTabs
+        mbArr <- local (second $ const n') $ insertJSONTextM isInsertOnly
+          mapTypes mapTabs
           childRi qfs' (fromText . (.fromName) <$> child.info)
           ((<> sn) . fromText . (.toName) <$> child.info)
         pure (fromText child.jsonName, mbArr)
@@ -279,10 +327,6 @@
       _ -> P.id) mempty
     (iplains, ichildren) = splitFields ri.fields
     (qplains, qchildren) = splitFields qfs
-    mbKeyMand = mapTabs M.!? ri.tabName <&> ((,)
-      <$> fmap fromText . (.tiDef.tdKey)
-      <*> fmap fromText . M.keys
-        . M.filter (\fd -> not $ fd.fdNullable || fd.fdHasDefault) . (.tiFlds))
     qualTabName = fromText (qualName ri.tabName)
     qcFlds = fmap ((,) <$> (.toName) <*> qualName . (.toDef.fdType))
       $ nubBy ((==) `on` (.toName)) $ ichildren >>= (\x -> (fst x).info)
@@ -290,6 +334,7 @@
     qretPairs = fmap (bimap fromText fromText)
       $ nubBy ((==) `on` fst) $ qcFlds <> qpFlds
     nameVal name val = name <> " = " <> val
+    keyValDistinct name val = name <> " IS NOT DISTINCT FROM " <> val
     noRets = P.null qplains && P.null ichildren
     qretFlds = fst <$> qretPairs
     addSemiColon = \case
diff --git a/src/PgSchema/DML/Select/Types.hs b/src/PgSchema/DML/Select/Types.hs
--- a/src/PgSchema/DML/Select/Types.hs
+++ b/src/PgSchema/DML/Select/Types.hs
@@ -233,7 +233,7 @@
 
 -- | Comparison constructors; each is paired with its corresponding operator
 -- (e.g. '(:=)' with '(=?)').
-data Cmp = (:=) | (:<=) | (:>=) | (:>) | (:<) | Like | ILike
+data Cmp = (:=) | (:<=) | (:>=) | (:>) | (:<) | Like | ILike | NotDistinctFrom
   deriving (Show, Eq, Generic)
 
 -- | Just boolean operations
@@ -248,6 +248,7 @@
   (:>)  -> ">"
   Like  -> "like"
   ILike -> "ilike"
+  NotDistinctFrom -> "is not distinct from"
 
 {- | RWS-Monad to generate condition.
 * Read: Stack of numbers of parent tables. The top is "current table"
@@ -419,6 +420,7 @@
 {-# INLINE (<=?) #-}
 {-# INLINE (>=?) #-}
 {-# INLINE (=?) #-}
+{-# INLINE (=??) #-}
 {-# INLINE (~=?) #-}
 {-# INLINE (~~?) #-}
 (<?),(>?),(<=?),(>=?),(=?)
@@ -428,11 +430,19 @@
 x <=? b = Cmp @x (:<=) b
 x >=? b = Cmp @x (:>=) b
 x =? b = Cmp @x (:=) b
+-- | Null-safe equality (@IS NOT DISTINCT FROM@) for nullable columns only.
+(=??)
+  :: forall fld -> forall ren sch tab v
+  . ( CDBFieldNullable sch tab (ApplyRenamer ren fld)
+    , CDBValue sch tab (ApplyRenamer ren fld) v
+    )
+  => v -> Cond ren sch tab
+x =?? b = Cmp @x NotDistinctFrom b
 (~=?),(~~?)
   :: forall fld -> forall ren sch tab v. CDBValue sch tab (ApplyRenamer ren fld) v => v -> Cond ren sch tab
 x ~=? b  = Cmp @x Like b
 x ~~? b  = Cmp @x ILike b
-infix 4 <?, >?, <=?, >=?, =?, ~=?, ~~?
+infix 4 <?, >?, <=?, >=?, =?, =??, ~=?, ~~?
 
 data OrdDirection = Asc | Desc deriving Show
 
diff --git a/src/PgSchema/Schema.hs b/src/PgSchema/Schema.hs
--- a/src/PgSchema/Schema.hs
+++ b/src/PgSchema/Schema.hs
@@ -136,15 +136,27 @@
 
 type RestMandatory sch t rs = RestMandatory' sch t rs (TdFlds (TTabDef sch t)) '[]
 
-type family RestPK' (rs :: [Symbol]) (fs :: [Symbol]) (res :: [Symbol]) :: [Symbol] where
-  RestPK' rs '[] res = res
-  RestPK' rs (fld ': fs) res = RestPK'' rs fs fld res (Elem' fld rs)
+type family RestKey' (rs :: [Symbol]) (fs :: [Symbol]) (res :: [Symbol]) :: [Symbol] where
+  RestKey' rs '[] res = res
+  RestKey' rs (fld ': fs) res = RestKey'' rs fs fld res (Elem' fld rs)
 
-type family RestPK'' rs fs fld (res :: [Symbol]) (b :: Bool) :: [Symbol] where
-  RestPK'' rs fs fld res 'True = RestPK' rs fs res
-  RestPK'' rs fs fld res 'False = RestPK' rs fs (fld ': res)
+type family RestKey'' rs fs fld (res :: [Symbol]) (b :: Bool) :: [Symbol] where
+  RestKey'' rs fs fld res 'True = RestKey' rs fs res
+  RestKey'' rs fs fld res 'False = RestKey' rs fs (fld ': res)
 
-type RestPK sch t rs = RestPK' rs (TdKey (TTabDef sch t)) '[]
+-- | Columns still missing from @rs@ to complete candidate key @keyCols@.
+type RestKey rs keyCols = RestKey' rs keyCols '[]
+
+type family AppendPK (pk :: [Symbol]) (uks :: [[Symbol]]) :: [[Symbol]] where
+  AppendPK '[] uks = uks
+  AppendPK pk uks = pk ': uks
+
+-- | Keys accepted by 'CheckAllMandatoryOrHasKey': PK plus every unique
+-- constraint from the schema (catalog order). Runtime key choice uses
+-- 'PgSchema.DML.InsertJSON.identityCandidatesFromTab' and its priority rules.
+type family IdentityCandidates sch tab :: [[Symbol]] where
+  IdentityCandidates sch tab =
+    AppendPK (TdKey (TTabDef sch tab)) (TdUniq (TTabDef sch tab))
 
 simpleType :: Text -> TypDef
 simpleType c = TypDef c Nothing []
diff --git a/src/PgSchema/Types.hs b/src/PgSchema/Types.hs
--- a/src/PgSchema/Types.hs
+++ b/src/PgSchema/Types.hs
@@ -397,9 +397,71 @@
 --   synonym below).
 --
 newtype PgTag s t = PgTag { unPgTag :: t }
-  deriving stock (Show, Read, Eq, Ord, Functor, Foldable, Traversable)
+  deriving stock (Eq, Ord, Functor, Foldable, Traversable)
   deriving newtype (Semigroup, Monoid)
 
+-- | Parse legacy @PgTag { unPgTag = … }@ (and minor spacing variants).
+--
+-- >>> P.show (fst (P.head (readsPgTagLegacy "PgTag { unPgTag = 9 }" :: [(PgTag "z" Int, String)])))
+-- "\"z\" =: 9"
+readsPgTagLegacy :: Read t => ReadS (PgTag s t)
+readsPgTagLegacy r =
+  [ (PgTag v, z)
+  | ("PgTag", r1) <- lex r
+  , ("{", r2) <- lex r1
+  , ("unPgTag", r3) <- lex r2
+  , ("=", r4) <- lex r3
+  , (v, r5) <- reads r4
+  , ("}", z) <- lex r5
+  ]
+
+-- | When @s :: 'Symbol'@ and @t@ is 'Show', render like @\"field\" =: value@:
+--
+-- >>> P.show ("ownerId" =: (1 :: Int))
+-- "\"ownerId\" =: 1"
+instance {-# OVERLAPPING #-} (KnownSymbol s, Show t) =>
+  Show (PgTag (s :: Symbol) t) where
+  showsPrec p (PgTag x) =
+    showParen (p > 5) $
+      showString (P.show (TL.symbolVal (Proxy @s) :: String))
+        . showString " =: "
+        . showsPrec 6 x
+
+-- | Accept @\"field\" =: value@ or the legacy @PgTag { unPgTag = … }@ form:
+--
+-- >>> P.show (read "\"x\" =: 42" :: PgTag "x" Int)
+-- "\"x\" =: 42"
+-- >>> P.show (read "PgTag { unPgTag = 42 }" :: PgTag "x" Int)
+-- "\"x\" =: 42"
+instance {-# OVERLAPPING #-} (KnownSymbol s, Read t) =>
+  Read (PgTag (s :: Symbol) t) where
+  readsPrec p r =
+    readParen (p > 5) tryEqColon r P.++ readParen (p > 11) readsPgTagLegacy r
+    where
+      tryEqColon s =
+        [ (PgTag a, u)
+        | (expected, t) <- reads s
+        , expected == (TL.symbolVal (Proxy @s) :: String)
+        , ("=:", u0) <- lex t
+        , (a, u) <- readsPrec 6 u0
+        ]
+
+-- | If @s@ is not a 'Symbol', fall back to record-style 'Show':
+--
+-- >>> P.show (PgTag @Bool (42 :: Int))
+-- "PgTag {unPgTag = 42}"
+instance {-# OVERLAPPABLE #-} Show t => Show (PgTag s t) where
+  showsPrec p (PgTag x) =
+    showParen (p > 11) $
+      showString "PgTag {unPgTag = " . shows x . showString "}"
+
+-- | Uses only 'readsPgTagLegacy' (see there for a doctest).
+--
+-- >>> P.show (read "PgTag { unPgTag = 42 }" :: PgTag Bool Int)
+-- "PgTag {unPgTag = 42}"
+instance {-# OVERLAPPABLE #-} Read t => Read (PgTag s t) where
+  readsPrec p = readParen (p > 11) readsPgTagLegacy
+
 type s := t = PgTag s t
 infixr 5 :=
 
@@ -409,7 +471,6 @@
 
 newtype UnsafeCol (flds :: [Symbol]) (expr :: Symbol) res = UnsafeCol
   { getUnsafeCol :: res }
-  -- deriving newtype (FromField, FromJSON)
 
 instance (FromField res, AllFields sch tab flds, CheckExpr flds expr) =>
   FromField (PgTag '(sch, tab :: NameNSK) (UnsafeCol flds expr res)) where
diff --git a/test-gen/Main.hs b/test-gen/Main.hs
--- a/test-gen/Main.hs
+++ b/test-gen/Main.hs
@@ -90,6 +90,16 @@
         constraint leaf_mid2_fk foreign key (root_id, seq) references mid2 (root_id, seq) on delete cascade
       );
 
+      -- Upsert by nullable unique key when table has no NOT NULL UK.
+      create table nullable_uq_row (
+        id     serial primary key,
+        code   text not null,
+        suffix text,
+        name   text not null,
+        note   text,
+        constraint nullable_uq_row_code_suffix_uq unique (code, suffix)
+      );
+
       -- Table for array types (nullable elements, no 2D arrays).
       create table arrays (
         id             serial primary key,
diff --git a/test-pgs/Main.hs b/test-pgs/Main.hs
--- a/test-pgs/Main.hs
+++ b/test-pgs/Main.hs
@@ -10,6 +10,8 @@
 
 import Tests.BaseConverts
 import Tests.Hierarchy
+import Tests.InsertJSONTransaction
+import Tests.UpsertUniqueKey
 import Tests.Conditions
 
 prop_not_implemented :: Property
@@ -34,6 +36,26 @@
       , testProperty "Duplicate field names in root and nested structure" $ prop_hier_duplicate_names_root_nested pool
       -- , testProperty "Optional parent FK on root (dim_a_id)" $
       --     prop_hier_insert_optional_parent_dim_a pool
+      ]
+    , testGroup "InsertJSON transactions (test_dml)"
+      [ testProperty "Standalone call commits" $
+          prop_insertJSON_standalone_commits pool
+      , testProperty "Outer transaction is not committed" $
+          prop_insertJSON_respects_outer_transaction pool
+      , testProperty "BEGIN without prior writes rolls back" $
+          prop_insertJSON_begin_without_prior_writes pool
+      , testProperty "Failure rolls back standalone transaction" $
+          prop_insertJSON_rolls_back_on_failure pool
+      ]
+    , testGroup "Upsert by unique key (test_dml)"
+      [ testProperty "upsertJSON on composite unique without PK in payload" $
+          prop_upsert_json_by_composite_unique pool
+      , testProperty "upsertJSON on nullable unique without NOT NULL UK" $
+          prop_upsert_json_by_nullable_unique pool
+      , testProperty "upsertJSON UPDATE by nullable unique key" $
+          prop_upsert_json_update_by_nullable_unique pool
+      , testProperty "upsertJSON null in nullable key inserts again" $
+          prop_upsert_json_nullable_key_null_inserts_twice pool
       ]
     , testGroup "Query (test_dml)"
       [ testProperty "'Simple' queries" $ prop_cond_query pool
diff --git a/test-pgs/Sch.hs b/test-pgs/Sch.hs
--- a/test-pgs/Sch.hs
+++ b/test-pgs/Sch.hs
@@ -71,11 +71,12 @@
   TTabDefSch ( "test_pgs" ->> "leaf" ) = 'TabDef '[ "root_id","seq","leaf_no","value","category","created_at" ] '[ "root_id","seq","leaf_no" ] '[  ]
   TTabDefSch ( "test_pgs" ->> "mid1" ) = 'TabDef '[ "id","root_id","pos","flag","sort_key","payload" ] '[ "id" ] '[  ]
   TTabDefSch ( "test_pgs" ->> "mid2" ) = 'TabDef '[ "root_id","seq","kind","flag","priority","payload" ] '[ "root_id","seq" ] '[  ]
+  TTabDefSch ( "test_pgs" ->> "nullable_uq_row" ) = 'TabDef '[ "id","code","suffix","name","note" ] '[ "id" ] '[ '[ "code","suffix" ] ]
   TTabDefSch ( "test_pgs" ->> "root" ) = 'TabDef '[ "id","code","grp","name","created_at","dim_a_id","dim_b_id" ] '[ "id" ] '[ '[ "code","grp" ] ]
   TTabDefSch name = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch TE.:$$: TE.Text "table " TE.:<>: TE.ShowType name TE.:<>: TE.Text " is not defined."
     TE.:$$: TE.Text ""
     TE.:$$: TE.Text "Valid values are:"
-    TE.:$$: TE.Text "  Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.root."
+    TE.:$$: TE.Text "  Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.nullable_uq_row, test_pgs.root."
     TE.:$$: TE.Text "")
 instance (ToStar (TTabDef Sch name), ToStar name) => CTabDef Sch name where
   type TTabDef Sch name = TTabDefSch name
@@ -117,13 +118,15 @@
 
   TFromSch ( "test_pgs" ->> "mid2" ) = '[ ( "test_pgs" ->> "mid2_root_fk" ) ]
 
+  TFromSch ( "test_pgs" ->> "nullable_uq_row" ) = '[  ]
+
   TFromSch ( "test_pgs" ->> "root" ) = '[ ( "test_pgs" ->> "root_dim_a_fk" )
     ,( "test_pgs" ->> "root_dim_b_fk" ) ]
 
   TFromSch tab = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch TE.:$$: TE.Text "TFrom for table " TE.:<>: TE.ShowType tab TE.:<>: TE.Text " is not defined."
     TE.:$$: TE.Text ""
     TE.:$$: TE.Text "Valid values are:"
-    TE.:$$: TE.Text "  Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.root."
+    TE.:$$: TE.Text "  Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.nullable_uq_row, test_pgs.root."
     TE.:$$: TE.Text "")
 
 type family TToSch (tab :: NameNSK) :: [NameNSK] where
@@ -146,13 +149,15 @@
 
   TToSch ( "test_pgs" ->> "mid2" ) = '[ ( "test_pgs" ->> "leaf_mid2_fk" ) ]
 
+  TToSch ( "test_pgs" ->> "nullable_uq_row" ) = '[  ]
+
   TToSch ( "test_pgs" ->> "root" ) = '[ ( "test_pgs" ->> "arrays_root_fk" )
     ,( "test_pgs" ->> "mid1_root_fk" ),( "test_pgs" ->> "mid2_root_fk" ) ]
 
   TToSch tab = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch TE.:$$: TE.Text "TTo for table " TE.:<>: TE.ShowType tab TE.:<>: TE.Text " is not defined."
     TE.:$$: TE.Text ""
     TE.:$$: TE.Text "Valid values are:"
-    TE.:$$: TE.Text "  Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.root."
+    TE.:$$: TE.Text "  Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.nullable_uq_row, test_pgs.root."
     TE.:$$: TE.Text "")
 instance CTabRels Sch tab where
   type TFrom Sch tab = TFromSch tab
@@ -313,6 +318,21 @@
     TE.:$$: TE.Text ""
     TE.:$$: TE.Text "Your source or target type or renaimer is probably invalid."
     TE.:$$: TE.Text "")
+  TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "code" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'False 'False)
+  TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "id" = 'RFPlain ('FldDef ( "pg_catalog" ->> "int4" ) 'False 'True)
+  TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "name" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'False 'False)
+  TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "note" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'True 'False)
+  TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "suffix" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'True 'False)
+  TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) f = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch
+    TE.:$$: TE.Text "for table " TE.:<>: TE.ShowType ( "test_pgs" ->> "nullable_uq_row" )
+    TE.:$$: TE.Text "name " TE.:<>: TE.ShowType f TE.:<>: TE.Text " is not defined."
+    TE.:$$: TE.Text ""
+    TE.:$$: TE.Text "Valid values are:"
+    TE.:$$: TE.Text "  Fields: id, code, suffix, name, note."
+    TE.:$$: TE.Text "  Foreign key constraints: ."
+    TE.:$$: TE.Text ""
+    TE.:$$: TE.Text "Your source or target type or renaimer is probably invalid."
+    TE.:$$: TE.Text "")
   TDBFieldInfoSch ( "test_pgs" ->> "root" ) "code" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'False 'False)
   TDBFieldInfoSch ( "test_pgs" ->> "root" ) "created_at" = 'RFPlain ('FldDef ( "pg_catalog" ->> "timestamptz" ) 'False 'True)
   TDBFieldInfoSch ( "test_pgs" ->> "root" ) "dim_a_id" = 'RFPlain ('FldDef ( "pg_catalog" ->> "int4" ) 'True 'False)
@@ -348,10 +368,11 @@
 
 instance CSchema Sch where
   type TTabs Sch = '[ ( "test_pgs" ->> "arrays" ),( "test_pgs" ->> "base_arr_converts" )
-    ,( "test_pgs" ->> "base_converts" ),( "test_pgs" ->> "dim" )
-    ,( "test_pgs" ->> "ext_arr_converts" ),( "test_pgs" ->> "ext_converts" )
-    ,( "test_pgs" ->> "leaf" ),( "test_pgs" ->> "mid1" )
-    ,( "test_pgs" ->> "mid2" ),( "test_pgs" ->> "root" ) ]
+    ,( "test_pgs" ->> "base_converts" )
+    ,( "test_pgs" ->> "dim" ),( "test_pgs" ->> "ext_arr_converts" )
+    ,( "test_pgs" ->> "ext_converts" ),( "test_pgs" ->> "leaf" )
+    ,( "test_pgs" ->> "mid1" ),( "test_pgs" ->> "mid2" )
+    ,( "test_pgs" ->> "nullable_uq_row" ),( "test_pgs" ->> "root" ) ]
 
   type TTypes Sch = '[ ( "pg_catalog" ->> "_bool" )
     ,( "pg_catalog" ->> "_bytea" ),( "pg_catalog" ->> "_date" )
diff --git a/test-pgs/Tests/InsertJSONTransaction.hs b/test-pgs/Tests/InsertJSONTransaction.hs
new file mode 100644
--- /dev/null
+++ b/test-pgs/Tests/InsertJSONTransaction.hs
@@ -0,0 +1,102 @@
+{-# LANGUAGE BlockArguments #-}
+module Tests.InsertJSONTransaction where
+
+import Control.Exception (SomeException, try)
+import Control.Monad (void, when)
+import Data.Functor
+import Data.Int (Int32, Int64)
+import Data.List qualified as L
+import Data.Pool as Pool
+import Data.Text (Text)
+import Database.PostgreSQL.Simple
+import Hedgehog
+import Utils
+import PgSchema.DML
+
+type RootRec = "code" := Text :. "grp" := Int32 :. "name" := Text :. "someEmpty" := ()
+
+type RootSel = "code" := Text :. "grp" := Int32 :. "name" := Text
+
+eqRoot :: RootRec -> RootRec -> Bool
+eqRoot (a1 :. b1 :. c1) (a2 :. b2 :. c2) = (a1, b1) == (a2, b2)
+
+countRoots :: Connection -> IO Int64
+countRoots conn = do
+  [cnt] <- fst <$> selSch "root" conn qpEmpty
+  pure $ unAggr @ACount $ unPgTag @"cnt" cnt
+
+countRootsCode :: Connection -> Text -> IO Int64
+countRootsCode conn code = do
+  [cnt] <- fst <$> selSch "root" conn (qRoot $ qWhere $ "code" =? code)
+  pure $ unAggr @ACount $ unPgTag @"cnt" cnt
+
+clearRoots :: Connection -> IO ()
+clearRoots conn = void $ delByCond "root" conn mempty
+
+prop_insertJSON_standalone_commits :: Pool Connection -> Property
+prop_insertJSON_standalone_commits pool = property do
+  rootsIn <- forAll (L.nubBy eqRoot <$> genData' RootRec 1 5)
+  evalIO do
+    Pool.withResource pool clearRoots
+    Pool.withResource pool \conn -> void $ insJSON_ "root" conn rootsIn
+    Pool.withResource pool \conn -> do
+      n <- countRoots conn
+      when (n /= fromIntegral (length rootsIn)) $
+        fail $ "expected " <> show (length rootsIn) <> " rows, got " <> show n
+
+prop_insertJSON_respects_outer_transaction :: Pool Connection -> Property
+prop_insertJSON_respects_outer_transaction pool = property do
+  rootsIn <- forAll (L.nubBy eqRoot <$> genData' RootRec 1 5)
+  evalIO do
+    Pool.withResource pool clearRoots
+    Pool.withResource pool \conn -> do
+      execute_ conn "begin"
+      void $ insJSON_ "root" conn rootsIn
+      n <- countRoots conn
+      when (n /= fromIntegral (length rootsIn)) $
+        fail $ "expected " <> show (length rootsIn) <> " rows in session, got "
+          <> show n
+      Pool.withResource pool \conn2 -> do
+        n2 <- countRoots conn2
+        when (n2 /= 0) $
+          fail $ "uncommitted rows visible in other session: " <> show n2
+      rollback conn
+    Pool.withResource pool \conn -> do
+      n <- countRoots conn
+      when (n /= 0) $
+        fail $ "rows remained after rollback: " <> show n
+
+prop_insertJSON_begin_without_prior_writes :: Pool Connection -> Property
+prop_insertJSON_begin_without_prior_writes pool = property do
+  rootsIn <- forAll (L.nubBy eqRoot <$> genData' RootRec 1 5)
+  evalIO do
+    Pool.withResource pool clearRoots
+    Pool.withResource pool \conn -> do
+      execute_ conn "begin"
+      void $ insJSON_ "root" conn rootsIn
+      rollback conn
+    Pool.withResource pool \conn -> do
+      n <- countRoots conn
+      when (n /= 0) $
+        fail $ "rows remained after rollback from empty begin: " <> show n
+
+prop_insertJSON_rolls_back_on_failure :: Pool Connection -> Property
+prop_insertJSON_rolls_back_on_failure pool = property do
+  evalIO do
+    Pool.withResource pool clearRoots
+    let
+      row :: RootRec
+      row = "code" =: "dup_tx" :. "grp" =: (1 :: Int32)
+        :. "name" =: "x" :. "someEmpty" =: ()
+    Pool.withResource pool \conn -> do
+      res <- try @SomeException $ insJSON_ "root" conn [row, row]
+      case res of
+        Left _ -> pure ()
+        Right _ -> fail "expected unique violation"
+      n <- countRootsCode conn "dup_tx"
+      when (n /= 0) $
+        fail $ "partial insert remained after failure: " <> show n
+    Pool.withResource pool \conn -> do
+      n <- countRootsCode conn "dup_tx"
+      when (n /= 0) $
+        fail $ "failed insert visible in other session: " <> show n
diff --git a/test-pgs/Tests/UpsertUniqueKey.hs b/test-pgs/Tests/UpsertUniqueKey.hs
new file mode 100644
--- /dev/null
+++ b/test-pgs/Tests/UpsertUniqueKey.hs
@@ -0,0 +1,124 @@
+{-# LANGUAGE BlockArguments #-}
+module Tests.UpsertUniqueKey where
+
+import Control.Monad (void, when)
+import Data.Int (Int32)
+import Data.Maybe (Maybe(..))
+import Data.List qualified as L
+import Data.Pool as Pool
+import Data.Text (Text)
+import Database.PostgreSQL.Simple (Connection)
+import Hedgehog (Property, discard, evalIO, forAll, property, withTests)
+import Hedgehog.Gen qualified as Gen
+import Hedgehog.Range qualified as Range
+import PgSchema.DML
+import Utils
+
+type RootRec =
+  "code" := Text :. "grp" := Int32 :. "name" := Text :. "someEmpty" := ()
+
+type RootSel = "code" := Text :. "grp" := Int32 :. "name" := Text
+
+-- | Upsert by composite unique @(code, grp)@ when PK @id@ is omitted; second call
+-- updates the same row.
+prop_upsert_json_by_composite_unique :: Pool Connection -> Property
+prop_upsert_json_by_composite_unique pool = withTests 5 $ property do
+  code <- forAll (Gen.text (Range.linear 3 24) Gen.alphaNum)
+  name1 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)
+  name2 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)
+  when (name1 == name2) discard
+  let grp = 4242 :: Int32
+  evalIO $ Pool.withResource pool \conn -> do
+    void $ delByCond "root" conn ("code" =? code)
+    let
+      row1 :: RootRec
+      row1 = "code" =: code :. "grp" =: grp :. "name" =: name1 :. "someEmpty" =: ()
+      row2 :: RootRec
+      row2 = "code" =: code :. "grp" =: grp :. "name" =: name2 :. "someEmpty" =: ()
+    void $ upsJSON_ "root" conn [row1]
+    void $ upsJSON_ "root" conn [row2]
+    (rows :: [RootSel], _) <- selSch "root" conn $
+      qRoot $ qWhere $ "code" =? code &&& "grp" =? grp
+    case rows of
+      [_ :. _ :. n] ->
+        when (unPgTag n /= name2) $
+          fail $ "expected updated name " <> show name2 <> ", got " <> show (unPgTag n)
+      _ -> fail $ "expected 1 row, got " <> show (L.length rows)
+
+-- | Upsert by nullable unique @(code, suffix)@ when table has no NOT NULL UK.
+prop_upsert_json_by_nullable_unique :: Pool Connection -> Property
+prop_upsert_json_by_nullable_unique pool = withTests 5 $ property do
+  code <- forAll (Gen.text (Range.linear 3 24) Gen.alphaNum)
+  suffix <- forAll (Gen.text (Range.linear 1 12) Gen.alphaNum)
+  name1 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)
+  name2 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)
+  when (name1 == name2) discard
+  let sfx = Just suffix
+  evalIO $ Pool.withResource pool \conn -> do
+    void $ delByCond "nullable_uq_row" conn ("code" =? code)
+    let
+      row1 = "code" =: code :. "suffix" =: sfx :. "name" =: name1
+        :. "note" =: (Nothing :: Maybe Text) :. "someEmpty" =: ()
+      row2 = "code" =: code :. "suffix" =: sfx :. "name" =: name2
+        :. "note" =: (Nothing :: Maybe Text) :. "someEmpty" =: ()
+    void $ upsJSON_ "nullable_uq_row" conn [row1]
+    void $ upsJSON_ "nullable_uq_row" conn [row2]
+    (rows :: ["name" := Text], _) <- selSch "nullable_uq_row" conn $
+      qRoot $ qWhere $ "code" =? code &&& "suffix" =?? sfx
+    case rows of
+      [n] ->  when (unPgTag n /= name2) $ fail
+        $ "expected updated name " <> show name2 <> ", got " <> show (unPgTag n)
+      _ -> fail $ "expected 1 row, got " <> show (L.length rows)
+
+-- | UPDATE branch: key only + optional field, mandatory @name@ omitted.
+prop_upsert_json_update_by_nullable_unique :: Pool Connection -> Property
+prop_upsert_json_update_by_nullable_unique pool = withTests 5 $ property do
+  code <- forAll (Gen.text (Range.linear 3 24) Gen.alphaNum)
+  suffix <- forAll (Gen.text (Range.linear 1 12) Gen.alphaNum)
+  note1 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)
+  note2 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)
+  when (note1 == note2) discard
+  let
+    sfx = Just suffix
+    fullName = "seed-name"
+  evalIO $ Pool.withResource pool \conn -> do
+    void $ delByCond "nullable_uq_row" conn ("code" =? code)
+    let
+      seed =
+        "code" =: code :. "suffix" =: sfx :. "name" =: fullName
+          :. "note" =: Just note1 :. "someEmpty" =: ()
+      patch =
+        "code" =: code :. "suffix" =: sfx :. "note" =: Just note2
+          :. "someEmpty" =: ()
+    void $ upsJSON_ "nullable_uq_row" conn [seed]
+    void $ upsJSON_ "nullable_uq_row" conn [patch]
+    (rows :: ["name" := Text :. "note" := Maybe Text], _) <-
+      selSch "nullable_uq_row" conn
+        $ qRoot $ qWhere $ "code" =? code &&& "suffix" =?? sfx
+    case rows of
+      [nm :. note] -> do
+        when (unPgTag note /= Just note2) $
+          fail $ "expected note " <> show note2 <> ", got " <> show (unPgTag note)
+        when (unPgTag nm /= fullName) $
+          fail $ "name should be unchanged, got " <> show (unPgTag nm)
+      _ -> fail $ "expected 1 row, got " <> show (L.length rows)
+
+-- | NULL in nullable key column: classic UNIQUE allows duplicate keys.
+prop_upsert_json_nullable_key_null_inserts_twice :: Pool Connection -> Property
+prop_upsert_json_nullable_key_null_inserts_twice pool = withTests 3 $ property do
+  code <- forAll (Gen.text (Range.linear 3 24) Gen.alphaNum)
+  name1 <- forAll (Gen.text (Range.linear 1 16) Gen.alphaNum)
+  name2 <- forAll (Gen.text (Range.linear 1 16) Gen.alphaNum)
+  when (name1 == name2) discard
+  evalIO $ Pool.withResource pool \conn -> do
+    void $ delByCond "nullable_uq_row" conn ("code" =? code)
+    let
+      row nm =
+        "code" =: code :. "suffix" =: (Nothing :: Maybe Text) :. "name" =: nm
+          :. "note" =: (Nothing :: Maybe Text) :. "someEmpty" =: ()
+    void $ upsJSON_ "nullable_uq_row" conn [row name1]
+    void $ upsJSON_ "nullable_uq_row" conn [row name2]
+    (rows :: ["code" := Text], _) <- selSch "nullable_uq_row" conn $
+      qRoot $ qWhere $ "code" =? code &&& pnull "suffix"
+    when (L.length rows /= 2) $
+      fail $ "expected 2 rows with null suffix, got " <> show (L.length rows)
