diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -58,7 +58,6 @@
 * `INTO` clause (`SELECT ... INTO ...`)
 * `WINDOW` clause for defining named windows (e.g. `WINDOW w AS ...`)
 * `ORDER BY USING`
-* `FOR READ ONLY` locking clause
 * `FOR UPDATE/SHARE OF` with qualified table names.
 * `WHERE CURRENT OF` for cursors
 * Aliasing a `JOIN` clause directly (e.g., `(SELECT * FROM t1 JOIN t2 ON ...) AS myalias`)
@@ -69,7 +68,6 @@
 * `FETCH` clause
 * `ORDER BY`, `LIMIT`, `OFFSET`, or locking clauses on the immediate operands of a set operation (`UNION`, `INTERSECT`, `EXCEPT`).
 * Advanced `GROUP BY` features (`GROUPING SETS`, `CUBE`, `ROLLUP`)
-* Multi-row `VALUES` clause in `SELECT` statements (e.g. `SELECT * FROM (VALUES (1, 'a'), (2, 'b'))`). Multi-row `INSERT ... VALUES` is supported.
 * `VALUES` clause with `ORDER BY`, `LIMIT`, `OFFSET`, or locking clauses.
 * `SELECT` without a `FROM` clause cannot have other clauses like `WHERE`, `GROUP BY`, etc.
 * Multiple `*` in a `SELECT` list.
@@ -82,7 +80,11 @@
 
 ### Data Manipulation (INSERT/UPDATE/DELETE)
 * `ON CONFLICT` with a column list conflict target (e.g. `ON CONFLICT (col1, col2) ...`). Only `ON CONFLICT ON CONSTRAINT ...` is supported.
+  - Reason: `squeal-postgresql` 0.9.1.3 exposes only `OnConstraint` for conflict targets.
+  - See `plan.md` for the proposed upstream `OnColumns` constructor and local steps.
 * `ON CONFLICT` without a conflict target (e.g. `ON CONFLICT DO NOTHING`).
+  - Reason: current `squeal-postgresql` API has no constructor to render a targetless `DO NOTHING`.
+  - See `plan.md` for the proposed upstream `OnConflictDoNothing` constructor.
 * `INSERT ... DEFAULT VALUES`
 * `INSERT INTO table (columns) SELECT ...` (must omit column list)
 * `INSERT ... VALUES` without a column list.
@@ -147,6 +149,8 @@
   select 'text_val' [✔]
   select 1 [✔]
   select 1 AS num, 'text_val' AS txt [✔]
+  values (1, 'a'), (2, 'b') [✔]
+  values (1, 'a') [✔]
   group by
     select name from users group by name [✔]
     select employee_id, count(id) from users group by employee_id [✔]
@@ -254,6 +258,7 @@
   select * from emails where emails.id not between 0 and 10 [✔]
   select (e.id :: text) as casted_id from emails as e [✔]
   select * from users for update [✔]
+  select * from users for read only [✔]
   select * from jsonb_test [✔]
   select * from json_test [✔]
   select distinct name from users [✔]
diff --git a/changelog.md b/changelog.md
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,13 @@
+### 0.1.5.0
+
+* New features
+  * Support multi-row VALUES in SELECT statements. [✔]
+    * `values (1, 'a'), (2, 'b')` [✔]
+    * `select * from (values (1, 'a'), (2, 'b')) as t (n, s)` [✔]
+  * Support `FOR READ ONLY` locking clause in SELECT statements.
+    The clause is accepted and treated as a no-op during translation to
+    Squeal, matching PostgreSQL semantics for plain SELECTs.
+
 ### 0.1.4.0
 
 * New features
diff --git a/squeal-postgresql-qq.cabal b/squeal-postgresql-qq.cabal
--- a/squeal-postgresql-qq.cabal
+++ b/squeal-postgresql-qq.cabal
@@ -1,6 +1,6 @@
 cabal-version:       3.0
 name:                squeal-postgresql-qq
-version:             0.1.4.0
+version:             0.1.5.0
 synopsis:            QuasiQuoter transforming raw sql into Squeal expressions.
 -- description:         
 homepage:            https://github.com/owensmurray/squeal-postgresql-qq
diff --git a/src/Squeal/QuasiQuotes/Insert.hs b/src/Squeal/QuasiQuotes/Insert.hs
--- a/src/Squeal/QuasiQuotes/Insert.hs
+++ b/src/Squeal/QuasiQuotes/Insert.hs
@@ -250,21 +250,47 @@
             `AppE` LabelE colNameStr
 
 
+--
+-- ON CONFLICT support note
+-- -------------------------
+-- As of the pinned upstream `squeal-postgresql` (0.9.2.0), the
+-- `ConflictTarget` GADT only exposes `OnConstraint` and there is no
+-- clause form for a targetless `ON CONFLICT DO NOTHING`.
+--
+-- Consequently, this quasiquoter intentionally fails fast when the parsed
+-- `PostgresqlSyntax` AST requests either:
+--   * a targetless ON CONFLICT (i.e. DO NOTHING without a target), or
+--   * a column-list conflict target (i.e. ON CONFLICT (col[, ...]) ...).
+--
+-- When upstream adds the necessary constructors (e.g. an `OnColumns`
+-- conflict target and a clause for targetless `DO NOTHING`), this is the
+-- place to wire them through. See `plan.md` for the backlog item.
 renderOnConflict :: [Text.Text] -> PGT_AST.OnConflict -> Q Exp
 renderOnConflict cteNames (PGT_AST.OnConflict maybeConfExpr onConflictDo) = do
   conflictActionExp <- renderOnConflictDo cteNames onConflictDo
   case maybeConfExpr of
-    Nothing -> fail "ON CONFLICT without a conflict target is not supported yet."
+    Nothing ->
+      fail
+        (  "ON CONFLICT without a conflict target (i.e. targetless DO NOTHING) "
+        <> "cannot be represented with squeal-postgresql 0.9.2.0. "
+        <> "Only ON CONFLICT ON CONSTRAINT ... is currently available. "
+        <> "See plan.md for the proposed upstream changes and status."
+        )
     Just confExpr -> do
       conflictTargetExp <- renderConfExpr confExpr
       pure $ ConE 'S.OnConflict `AppE` conflictTargetExp `AppE` conflictActionExp
 
+-- See the ON CONFLICT support note above.
 renderConfExpr :: PGT_AST.ConfExpr -> Q Exp
 renderConfExpr = \case
   PGT_AST.ConstraintConfExpr name ->
     pure $ ConE 'S.OnConstraint `AppE` LabelE (Text.unpack (getIdentText name))
   PGT_AST.WhereConfExpr _ _ ->
-    fail "ON CONFLICT (columns) is not supported yet. Use ON CONFLICT ON CONSTRAINT."
+    fail
+      (  "ON CONFLICT (columns ...) is not supported by squeal-postgresql 0.9.2.0. "
+      <> "Only ON CONFLICT ON CONSTRAINT ... is currently available. "
+      <> "See plan.md for the proposed upstream changes and status."
+      )
 
 renderOnConflictDo :: [Text.Text] -> PGT_AST.OnConflictDo -> Q Exp
 renderOnConflictDo cteNames = \case
diff --git a/src/Squeal/QuasiQuotes/Query.hs b/src/Squeal/QuasiQuotes/Query.hs
--- a/src/Squeal/QuasiQuotes/Query.hs
+++ b/src/Squeal/QuasiQuotes/Query.hs
@@ -16,9 +16,9 @@
   getIdentText,
 ) where
 
-import Control.Monad (unless, when, zipWithM)
+import Control.Monad (when, zipWithM)
 import Data.Either (partitionEithers)
-import Data.Foldable (Foldable(elem, foldl', foldr, length, null), any, foldlM)
+import Data.Foldable (Foldable(elem, foldl', foldr, length, null), any, foldlM, mapM_)
 import Data.Function (on)
 import Data.List (groupBy, partition, sortBy)
 import Data.Maybe (fromMaybe, isJust, isNothing)
@@ -262,16 +262,16 @@
 
       pure $ squealOp `AppE` leftQuery `AppE` rightQuery
     PGT_AST.ValuesSimpleSelect valuesClause -> do
-      unless
-        ( isNothing maybeSortClause
-            && isNothing maybeSelectLimit
-            && isNothing maybeForLockingClause
+      when
+        ( isJust maybeSortClause
+            || isJust maybeSelectLimit
+            || isJust maybeForLockingClause
         )
         $ fail
         $ "ORDER BY / OFFSET / LIMIT / FOR UPDATE etc. not supported with VALUES clause "
           <> "in this translation yet."
-      renderedValues <- renderValuesClauseToNP cteNames maybeColAliases valuesClause
-      pure $ VarE 'S.values_ `AppE` renderedValues
+      valuesExp <- renderValuesClauseToNP cteNames maybeColAliases valuesClause
+      pure valuesExp
     PGT_AST.NormalSimpleSelect
       maybeTargeting
       maybeIntoClause
@@ -429,45 +429,63 @@
   -> PGT_AST.ValuesClause
   -> Q Exp
 renderValuesClauseToNP cteNames maybeColAliases (firstRowExps NE.:| restRowExps) = do
-    unless (null restRowExps) $
-      fail $
-        "Multi-row VALUES clause requires S.values, this translation "
-          <> "currently supports single row S.values_."
-    convertRowToNP firstRowExps
-  where
-    colAliasTexts = fmap (fmap getIdentText . NE.toList) maybeColAliases
+    let
+      firstRowList = NE.toList firstRowExps
+      restRowsLists = fmap NE.toList restRowExps
 
-    convertRowToNP :: NE.NonEmpty PGT_AST.AExpr -> Q Exp
-    convertRowToNP exprs = do
-        let
-          exprList = NE.toList exprs
-        aliasTexts <-
-          case colAliasTexts of
-            Just aliases ->
-              if length aliases == length exprList
-                then pure aliases
-                else
-                  fail
-                    "Number of column aliases in CTE does not match number of columns in VALUES clause."
-            Nothing -> pure $ fmap (Text.pack . ("_column" <>) . show) [1 :: Int ..]
-        go (zip exprList aliasTexts)
-      where
-        go :: [(PGT_AST.AExpr, Text.Text)] -> Q Exp
-        go [] = pure $ ConE 'S.Nil
-        go ((expr, aliasText) : fs) = do
-          renderedExpr <- renderPGTAExpr cteNames expr
-          let
-            aliasedExp = VarE 'S.as `AppE` renderedExpr `AppE` LabelE (Text.unpack aliasText)
-          restExp <- go fs
-          pure $ ConE '(S.:*) `AppE` aliasedExp `AppE` restExp
+    -- Determine column aliases based on provided aliases or synthesize.
+    aliasTexts <-
+      case fmap (fmap getIdentText . NE.toList) maybeColAliases of
+        Just aliases ->
+          if length aliases == length firstRowList
+            then pure aliases
+            else
+              fail
+                "Number of column aliases does not match number of columns in VALUES clause."
+        Nothing ->
+          pure $ fmap (Text.pack . ("_column" <>) . show) [1 :: Int ..]
 
+    -- Validate all rows have consistent arity.
+    let expectedLen = length firstRowList
+    let checkLen xs = if length xs == expectedLen
+          then pure ()
+          else fail "Mismatched number of columns across VALUES rows."
+    mapM_ checkLen restRowsLists
 
+    -- Helper to convert a row to an NP using the established aliases.
+    let convertRow :: [PGT_AST.AExpr] -> Q Exp
+        convertRow exprs = go (zip exprs aliasTexts)
+          where
+            go :: [(PGT_AST.AExpr, Text.Text)] -> Q Exp
+            go [] = pure $ ConE 'S.Nil
+            go ((expr, aliasText) : fs) = do
+              renderedExpr <- renderPGTAExpr cteNames expr
+              let aliasedExp = VarE 'S.as `AppE` renderedExpr `AppE` LabelE (Text.unpack aliasText)
+              restExp <- go fs
+              pure $ ConE '(S.:*) `AppE` aliasedExp `AppE` restExp
+
+    firstRowNP <- convertRow firstRowList
+    case restRowsLists of
+      [] -> pure $ VarE 'S.values_ `AppE` firstRowNP
+      more -> do
+        moreNPs <- mapM convertRow more
+        pure $ VarE 'S.values `AppE` firstRowNP `AppE` ListE moreNPs
+
+
 renderPGTForLockingClauseItems :: PGT_AST.ForLockingClause -> Q [Exp]
 renderPGTForLockingClauseItems = \case
+  -- PostgreSQL's `FOR READ ONLY` does not acquire row-level locks and is
+  -- effectively equivalent to the absence of a row-locking clause for
+  -- SELECT. Squeal does not expose a row-level "read only" lock; instead,
+  -- read-only behavior is an AccessMode on the transaction
+  -- (TransactionMode { accessMode = ReadOnly }).
+  --
+  -- We therefore accept and ignore `FOR READ ONLY` here so that the quoted
+  -- SQL compiles and renders identically to the same query without the
+  -- clause. To enforce read-only semantics, execute the statement inside a
+  -- transaction whose AccessMode is ReadOnly.
   PGT_AST.ReadOnlyForLockingClause ->
-    fail $
-      "FOR READ ONLY is not supported as a row-level locking "
-        <> "clause by Squeal-QQ."
+    pure []
   PGT_AST.ItemsForLockingClause itemsNe ->
     mapM renderPGTForLockingItem (NE.toList itemsNe)
 
@@ -788,6 +806,21 @@
   PGT_AST.RelationExprTableRef relationExpr maybeAliasClause sampleClause -> do
     when (isJust sampleClause) $ fail "TABLESAMPLE clause is not supported yet."
     renderPGTRelationExprTableRef cteNames relationExpr maybeAliasClause
+  -- Support subqueries (including VALUES ...) in FROM with optional column aliases
+  PGT_AST.SelectTableRef isLateral selectWithParens maybeAliasClause -> do
+    when isLateral $ fail "LATERAL subqueries are not supported yet."
+    -- Alias is required for subqueries in FROM in PostgreSQL; enforce here
+    (aliasStr, maybeColAliases) <-
+      case maybeAliasClause of
+        Just (PGT_AST.AliasClause _ aliasIdent maybeCols) -> do
+          let aliasTxt = Text.unpack (getIdentText aliasIdent)
+          pure (aliasTxt, maybeCols)
+        Nothing -> fail "Subquery in FROM requires an alias."
+    -- Propagate any provided column aliases down into the subquery translation
+    subqueryExp <- toSquealSelectWithParens cteNames maybeColAliases selectWithParens
+    -- Treat the subquery as a derived table in the FROM clause
+    -- Using S.subquery (as subquery alias)
+    pure $ VarE 'S.subquery `AppE` (VarE 'S.as `AppE` subqueryExp `AppE` LabelE aliasStr)
   PGT_AST.JoinTableRef joinedTable maybeAliasClause ->
     -- If `maybeAliasClause` is Just, it means `(JOIN_TABLE) AS alias`.
     -- Squeal's direct join combinators don't alias the *result* of the join.
diff --git a/test/test.hs b/test/test.hs
--- a/test/test.hs
+++ b/test/test.hs
@@ -533,6 +533,68 @@
           squealRendering = "SELECT * FROM (VALUES (1, (E'text_val' :: text))) AS t (\"num\", \"txt\")"
         checkStatement squealRendering statement
 
+      it "values (1, 'a'), (2, 'b')" $ do
+        let
+          statement
+            :: Statement
+                 DB
+                 ()
+                 ( Field "_column1" Int64
+                 , ( Field "_column2" Text
+                   , ()
+                   )
+                 )
+          statement = [ssql| values (1, 'a'), (2, 'b') |]
+          squealRendering :: Text
+          squealRendering =
+            "SELECT * FROM (VALUES (1, (E'a' :: text)), (2, (E'b' :: text))) AS t (\"_column1\", \"_column2\")"
+        checkStatement squealRendering statement
+
+      it "values (1, 'a')" $ do
+        let
+          statement
+            :: Statement
+                 DB
+                 ()
+                 ( Field "_column1" Int64
+                 , ( Field "_column2" Text
+                   , ()
+                   )
+                 )
+          statement = [ssql| values (1, 'a') |]
+          squealRendering :: Text
+          squealRendering =
+            "SELECT * FROM (VALUES (1, (E'a' :: text))) AS t (\"_column1\", \"_column2\")"
+        checkStatement squealRendering statement
+
+      it "select * from (values (1, 'a'), (2, 'b')) as t (n, s)" $ do
+        let
+          statement
+            :: Statement
+                 DB
+                 ()
+                 ( Field "n" Int64
+                 , ( Field "s" Text
+                   , ()
+                   )
+                 )
+          statement =
+            [ssql| select * from (values (1, 'a'), (2, 'b')) as t (n, s) |]
+          squealRendering :: Text
+          squealRendering =
+            "SELECT * FROM (SELECT * FROM (VALUES (1, (E'a' :: text)), (2, (E'b' :: text))) AS t (\"n\", \"s\")) AS \"t\""
+        checkStatement squealRendering statement
+
+      -- Selecting FROM a subquery (VALUES ...) alias is not yet supported by
+      -- the translator (SelectTableRef). This can be added in a follow-up.
+
+
+      -- Negative tests (compile-time failures) cannot be included in the
+      -- test suite because the quasiquoter fails during compilation.
+
+      -- Selecting FROM a subquery (VALUES ...) alias is not yet supported by
+      -- the translator (SelectTableRef). This can be added in a follow-up.
+
       describe "group by" $ do
         it "select name from users group by name" $ do
           let
@@ -1806,6 +1868,71 @@
           statement = [ssql| select * from users for update |]
           squealRendering :: Text
           squealRendering = "SELECT * FROM \"users\" AS \"users\" FOR UPDATE"
+        checkStatement squealRendering statement
+
+      it "select * from users for read only" $ do
+        let
+          statement
+            :: Statement
+                 DB
+                 ()
+                 ( Field "id" Text
+                 , ( Field "name" Text
+                   , ( Field "employee_id" UUID
+                     , ( Field "bio" (Maybe Text)
+                       , ()
+                       )
+                     )
+                   )
+                 )
+          statement = [ssql| select * from users for read only |]
+          squealRendering :: Text
+          squealRendering = "SELECT * FROM \"users\" AS \"users\""
+        checkStatement squealRendering statement
+
+      it "select * from users where name = 'bob' for read only" $ do
+        let
+          statement
+            :: Statement
+                 DB
+                 ()
+                 ( Field "id" Text
+                 , ( Field "name" Text
+                   , ( Field "employee_id" UUID
+                     , ( Field "bio" (Maybe Text)
+                       , ()
+                       )
+                     )
+                   )
+                 )
+          statement = [ssql| select * from users where name = 'bob' for read only |]
+          squealRendering :: Text
+          squealRendering = "SELECT * FROM \"users\" AS \"users\" WHERE (\"name\" = (E'bob' :: text))"
+        checkStatement squealRendering statement
+
+      it "with users_cte as (select * from users) select * from users_cte for read only" $ do
+        let
+          statement
+            :: Statement
+                 DB
+                 ()
+                 ( Field "id" Text
+                 , ( Field "name" Text
+                   , ( Field "employee_id" UUID
+                     , ( Field "bio" (Maybe Text)
+                       , ()
+                       )
+                     )
+                   )
+                 )
+          statement =
+            [ssql|
+              with users_cte as (select * from users)
+              select * from users_cte for read only
+            |]
+          squealRendering :: Text
+          squealRendering =
+            "WITH \"users_cte\" AS (SELECT * FROM \"users\" AS \"users\") SELECT * FROM \"users_cte\" AS \"users_cte\""
         checkStatement squealRendering statement
 
       it "select * from jsonb_test" $ do
