diff --git a/sbroad-cartridge/src/cartridge/router.rs b/sbroad-cartridge/src/cartridge/router.rs
index 56bf31fc72ad92ed66602fe410cc939a7e5f287d..f33f484345a6a5b175bb39d7babbf4f259b5c8cb 100644
--- a/sbroad-cartridge/src/cartridge/router.rs
+++ b/sbroad-cartridge/src/cartridge/router.rs
@@ -6,6 +6,7 @@ use std::any::Any;
 use std::cell::{Ref, RefCell};
 use std::collections::{HashMap, HashSet};
 use std::convert::TryInto;
+
 use std::rc::Rc;
 
 use tarantool::tlua::LuaFunction;
@@ -219,7 +220,7 @@ impl RouterRuntime {
             return Err(SbroadError::Invalid(
                 Entity::Buckets,
                 Some(format!("Expected Buckets::Filtered, got {buckets:?}")),
-            ));
+            ))
         };
         let random_bucket = self.get_random_bucket();
         let buckets = if bucket_set.is_empty() {
@@ -596,7 +597,7 @@ fn group(buckets: &Buckets) -> Result<HashMap<String, Vec<u64>>, SbroadError> {
         Buckets::All => {
             return Err(SbroadError::Unsupported(
                 Entity::Buckets,
-                Some("grouping buckets is not supported for all buckets".into()),
+                Some("grouping buckets is not supported for Buckets::All".into()),
             ))
         }
         Buckets::Filtered(list) => list.iter().copied().collect(),
diff --git a/sbroad-cartridge/src/cartridge/storage.rs b/sbroad-cartridge/src/cartridge/storage.rs
index d952c29312f8e67fe4e6107d74fdf593ff2e0447..b440b026f6aa860998c5ebff53c0ca8cf3ca1a07 100644
--- a/sbroad-cartridge/src/cartridge/storage.rs
+++ b/sbroad-cartridge/src/cartridge/storage.rs
@@ -207,7 +207,7 @@ impl StorageRuntime {
             ));
         }
 
-        let (pattern_with_params, tmp_spaces) = compile_encoded_optional(raw_optional)?;
+        let (pattern_with_params, _tmp_spaces) = compile_encoded_optional(raw_optional)?;
         debug!(
             Option::from("execute"),
             &format!(
@@ -220,9 +220,7 @@ impl StorageRuntime {
         } else {
             read_unprepared(&pattern_with_params.pattern, &pattern_with_params.params)
         };
-        for space in tmp_spaces {
-            drop(space);
-        }
+
         result
     }
 
@@ -272,7 +270,7 @@ impl StorageRuntime {
             &format!("Failed to find a plan (id {plan_id}) in the cache."),
         );
 
-        let (pattern_with_params, tmp_spaces) = compile_encoded_optional(raw_optional)?;
+        let (pattern_with_params, _tmp_spaces) = compile_encoded_optional(raw_optional)?;
         let result = match prepare(&pattern_with_params.pattern) {
             Ok(stmt) => {
                 let stmt_id = stmt.id()?;
@@ -331,9 +329,7 @@ impl StorageRuntime {
                 }
             }
         };
-        for space in tmp_spaces {
-            drop(space);
-        }
+
         result
     }
 }
diff --git a/sbroad-cartridge/test_app/test/data/config.yml b/sbroad-cartridge/test_app/test/data/config.yml
index 34580bed506176b245de7215e0dac0268c366d41..7444abd1ea2e633c13bdb31ef717b9696a612bf0 100644
--- a/sbroad-cartridge/test_app/test/data/config.yml
+++ b/sbroad-cartridge/test_app/test/data/config.yml
@@ -1093,4 +1093,3 @@ schema:
       engine: memtx
       sharding_key:
       - id
-
diff --git a/sbroad-cartridge/test_app/test/integration/api_test.lua b/sbroad-cartridge/test_app/test/integration/api_test.lua
index 16d3b0546c4d556c20977032f29441c94dd317b2..95bf11e55a16d0d4f713a7175d09229dc8f79637 100644
--- a/sbroad-cartridge/test_app/test/integration/api_test.lua
+++ b/sbroad-cartridge/test_app/test/integration/api_test.lua
@@ -198,7 +198,7 @@ g.test_query_errored = function()
 
     -- luacheck: max line length 140
     local _, err = api:call("sbroad.execute", { [[SELECT "NotFoundColumn" FROM "testing_space"]], {} })
-    t.assert_equals(tostring(err), "Sbroad Error: column with name [\"\\\"NotFoundColumn\\\"\"] not found")
+    t.assert_equals(tostring(err), "Sbroad Error: column with name \"NotFoundColumn\" not found")
 
     local invalid_type_param = datetime.new{
         nsec = 123456789,
diff --git a/sbroad-cartridge/test_app/test/integration/groupby_test.lua b/sbroad-cartridge/test_app/test/integration/groupby_test.lua
new file mode 100644
index 0000000000000000000000000000000000000000..5ef8f64d205efd1ce7933d33b5aa1efe8c92c6ca
--- /dev/null
+++ b/sbroad-cartridge/test_app/test/integration/groupby_test.lua
@@ -0,0 +1,760 @@
+local t = require('luatest')
+local groupby_queries = t.group('groupby_queries')
+
+local helper = require('test.helper.cluster_no_replication')
+local cluster = nil
+
+groupby_queries.before_all(
+        function()
+            helper.start_test_cluster(helper.cluster_config)
+            cluster = helper.cluster
+
+            local api = cluster:server("api-1").net_box
+
+            local r, err = api:call("sbroad.execute", {
+                [[INSERT INTO "testing_space" ("id", "name", "product_units") VALUES
+                (?, ?, ?),
+                (?, ?, ?),
+                (?, ?, ?),
+                (?, ?, ?),
+                (?, ?, ?),
+                (?, ?, ?)
+                ]],
+                {
+                    1, "123", 1,
+                    2, "1", 1,
+                    3, "1", 1,
+                    4, "2", 2,
+                    5, "123", 2,
+                    6, "2", 4
+                }
+            })
+            t.assert_equals(err, nil)
+            t.assert_equals(r, {row_count = 6})
+            r, err = api:call("sbroad.execute", {
+                [[
+                    INSERT INTO "arithmetic_space"
+                    ("id", "a", "b", "c", "d", "e", "f", "boolean_col", "string_col", "number_col")
+                    VALUES (?,?,?,?,?,?,?,?,?,?),
+                    (?,?,?,?,?,?,?,?,?,?),
+                    (?,?,?,?,?,?,?,?,?,?),
+                    (?,?,?,?,?,?,?,?,?,?)
+                ]],
+                {
+                    1, 1, 1, 1, 1, 2, 2, true, "a", 3.14,
+                    2, 1, 2, 1, 2, 2, 2, true, "a", 2,
+                    3, 2, 3, 1, 2, 2, 2, true, "c", 3.14,
+                    4, 2, 3, 1, 1, 2, 2, true, "c", 2.14
+                }
+            })
+
+            t.assert_equals(err, nil)
+            t.assert_equals(r, {row_count = 4})
+            r, err = api:call("sbroad.execute", {
+                [[
+                    INSERT INTO "arithmetic_space2"
+                    ("id", "a", "b", "c", "d", "e", "f", "boolean_col", "string_col", "number_col")
+                    VALUES (?,?,?,?,?,?,?,?,?,?),
+                    (?,?,?,?,?,?,?,?,?,?),
+                    (?,?,?,?,?,?,?,?,?,?),
+                    (?,?,?,?,?,?,?,?,?,?)
+                ]],
+                {
+                    1, 2, 1, 1, 1, 2, 2, true, "a", 3.1415,
+                    2, 2, 2, 1, 3, 2, 2, false, "a", 3.1415,
+                    3, 1, 1, 1, 1, 2, 2, false, "b", 2.718,
+                    4, 1, 1, 1, 1, 2, 2, true, "b", 2.717,
+                }
+            })
+
+            t.assert_equals(err, nil)
+            t.assert_equals(r, {row_count = 4})
+        end
+)
+
+groupby_queries.after_all(function()
+    local storage1 = cluster:server("storage-1-1").net_box
+    storage1:call("box.execute", { [[TRUNCATE TABLE "testing_space"]] })
+    storage1:call("box.execute", { [[TRUNCATE TABLE "arithmetic_space"]] })
+    storage1:call("box.execute", { [[TRUNCATE TABLE "arithmetic_space2"]] })
+
+    local storage2 = cluster:server("storage-2-1").net_box
+    storage2:call("box.execute", { [[TRUNCATE TABLE "testing_space"]] })
+    storage2:call("box.execute", { [[TRUNCATE TABLE "arithmetic_space"]] })
+    storage2:call("box.execute", { [[TRUNCATE TABLE "arithmetic_space2"]] })
+
+    helper.stop_test_cluster()
+end)
+
+groupby_queries.test_grouping = function()
+    local api = cluster:server("api-1").net_box
+
+    -- with GROUP BY
+    local r, err = api:call("sbroad.execute", { [[
+    SELECT "name"
+    FROM "testing_space"
+    GROUP BY "name"
+]], {} })
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "name", type = "string" },
+    })
+    t.assert_items_equals(r.rows, {
+        { "123" },
+        { "1" },
+        { "2" }
+    })
+
+    -- without GROUP BY
+    local r, err = api:call("sbroad.execute", { [[
+        SELECT "name"
+        FROM "testing_space"
+    ]], {} })
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "name", type = "string" },
+    })
+    t.assert_items_equals(r.rows, {
+        { "123" },
+        { "123" },
+        { "1" },
+        { "1" },
+        { "2" },
+        { "2" }
+    })
+end
+
+groupby_queries.expr_in_proj = function()
+    local api = cluster:server("api-1").net_box
+
+    local r, err = api:call("sbroad.execute", { [[
+    SELECT "name" || 'p' AS "name"
+    FROM "testing_space"
+    GROUP BY "name"
+]], {} })
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "name", type = "string" },
+    })
+    t.assert_items_equals(r.rows, {
+        { "123p" },
+        { "1p" },
+        { "2p" }
+    })
+
+    local r, err = api:call("sbroad.execute", { [[
+    SELECT "a" + "b" AS e1, "a" / "b" AS e2
+    FROM "arithmetic_space"
+    GROUP BY "a", "b"
+]], {} })
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "E1", type = "integer" },
+        { name = "E2", type = "integer" },
+    })
+    t.assert_items_equals(r.rows, {
+        {3, 0}, {5, 0}, {2, 1}
+    })
+
+end
+
+groupby_queries.different_column_types = function()
+    local api = cluster:server("api-1").net_box
+
+    -- DECIMAL
+    local r, err = api:call("sbroad.execute", { [[
+    SELECT *
+    FROM (SELECT cast("number_col" AS decimal) AS col FROM "arithmetic_space")
+    GROUP BY col
+]], {} })
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "COL", type = "decimal" },
+    })
+    t.assert_items_equals(r.rows, {
+        { 2 },
+        { 2.14 },
+        { 3.14 }
+    })
+
+    -- integer, boolean, STRING
+    r, err = api:call("sbroad.execute", { [[
+    SELECT "f", "boolean_col", "string_col"
+    FROM "arithmetic_space"
+    GROUP BY "f", "boolean_col", "string_col"
+]], {} })
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        {name = "f", type = "integer"},
+        {name = "boolean_col", type = "boolean"},
+        {name = "string_col", type = "string"},
+    })
+    t.assert_items_equals(r.rows, {
+        { 2, true, "a" },
+        { 2, true, "c" },
+    })
+
+    -- SCALAR
+    r, err = api:call("sbroad.execute", { [[
+    SELECT *
+    FROM (
+        SELECT CAST("number_col" AS SCALAR) AS u FROM "arithmetic_space"
+        UNION ALL
+        SELECT * FROM (
+            SELECT CAST("boolean_col" AS SCALAR) FROM "arithmetic_space"
+            UNION ALL
+            SELECT CAST("string_col" AS STRING) FROM "arithmetic_space"
+        )
+    )
+    GROUP BY u
+]], {} })
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        {name = "U", type = "scalar"},
+    })
+    t.assert_items_equals(r.rows, {
+        {2}, {true}, {2.14}, {3.14}, {"a"}, {"c"}
+    })
+
+    -- double, UNSIGNED
+    r, err = api:call("sbroad.execute", { [[
+    SELECT d, u
+    FROM (
+        SELECT CAST("number_col" AS DOUBLE) AS d, CAST("number_col" AS UNSIGNED) AS u FROM "arithmetic_space2"
+    )
+    GROUP BY d, u
+]], {} })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        {name = "D", type = "double"},
+        {name = "U", type = "unsigned"},
+    })
+    t.assert_items_equals(r.rows, {
+        {2.717, 2}, {3.1415, 3}, {2.718, 2},
+    })
+end
+
+groupby_queries.invalid = function()
+    local api = cluster:server("api-1").net_box
+
+    local _, err = api:call("sbroad.execute", { [[
+        SELECT "name" FROM "testing_space" groupBY "name"
+    ]], {} })
+    t.assert_str_contains(tostring(err), "rule parsing error")
+
+    local _, err = api:call("sbroad.execute", { [[
+        SELECT "id" + "product_units" FROM "testing_space" GROUP BY "id"
+    ]], {} })
+    t.assert_str_contains(tostring(err), "Invalid projection with GROUP BY clause")
+
+    local _, err = api:call("sbroad.execute", { [[
+        SELECT "name", "product_units" FROM "testing_space" GROUP BY "name"
+    ]], {} })
+    t.assert_str_contains(tostring(err), "Invalid projection with GROUP BY clause")
+
+    local _, err = api:call("sbroad.execute", { [[
+        SELECT "name" AS "q" FROM "testing_space" GROUP BY "q"
+    ]], {} })
+    t.assert_str_contains(tostring(err), "column with name \"q\" not found")
+
+    local _, err = api:call("sbroad.execute", { [[
+        SELECT "name", "product_units" FROM "testing_space" GROUP BY "name" "product_units"
+    ]], {} })
+    t.assert_str_contains(tostring(err), "rule parsing error")
+
+    local _, err = api:call("sbroad.execute", { [[
+        SELECT "product_units" FROM "testing_space" GROUP BY "name"
+    ]], {} })
+    t.assert_str_contains(tostring(err), "Invalid projection with GROUP BY clause")
+end
+
+groupby_queries.test_two_col = function()
+    local api = cluster:server("api-1").net_box
+
+    local r, err = api:call("sbroad.execute", { [[
+        SELECT "product_units", "name"
+        FROM "testing_space"
+        GROUP BY "product_units", "name"
+    ]], {} })
+
+    local expected = {
+        { 1, "123" },
+        { 1, "1" },
+        { 2, "2" },
+        { 2, "123" },
+        { 4, "2" },
+    };
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        {name = "product_units", type = "integer"},
+        {name = "name", type = "string"},
+    })
+    t.assert_items_equals(r.rows, expected)
+
+    r, err = api:call("sbroad.execute", { [[
+        SELECT "product_units", "name"
+        FROM "testing_space"
+        GROUP BY "name", "product_units"
+    ]], {} })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        {name = "product_units", type = "integer"},
+        {name = "name", type = "string"},
+    })
+    t.assert_items_equals(r.rows, expected)
+end
+
+groupby_queries.test_with_selection = function ()
+    local api = cluster:server("api-1").net_box
+
+    local r, err = api:call("sbroad.execute", { [[
+        SELECT "product_units", "name"
+        FROM "testing_space"
+        WHERE "product_units" > ?
+        GROUP BY "product_units", "name"
+    ]], {1} })
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        {name = "product_units", type = "integer"},
+        {name = "name", type = "string"},
+    })
+    t.assert_items_equals(r.rows, {
+        { 2, "2" },
+        { 2, "123" },
+        { 4, "2" },
+    })
+end
+
+groupby_queries.test_with_JOIN = function ()
+    local api = cluster:server("api-1").net_box
+
+    local r, err = api:call("sbroad.execute", { [[
+    SELECT "id", "id2"
+    FROM "arithmetic_space"
+    INNER JOIN
+        (SELECT "id" as "id2", "a" as "a2" from "arithmetic_space2") as t
+    ON "arithmetic_space"."id" = t."a2"
+    GROUP BY "id", "id2"
+]], {} })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "id", type = "integer" },
+        { name = "id2", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1, 3 },
+        { 1, 4 },
+        { 2, 1 },
+        { 2, 2 },
+    })
+end
+
+
+groupby_queries.test_with_join2 = function ()
+    local api = cluster:server("api-1").net_box
+    -- with groupBY
+    local r, err = api:call("sbroad.execute", { [[
+    SELECT "c", q.a1
+    FROM "arithmetic_space"
+    INNER JOIN
+        (SELECT "b" AS b1, "a" AS a1 FROM "arithmetic_space2") AS q
+    ON "arithmetic_space"."c" = q.a1
+    GROUP BY "c", a1
+]], {} })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "c", type = "integer" },
+        { name = "A1", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1, 1 },
+    })
+
+    -- without groupBY
+    r, err = api:call("sbroad.execute", { [[
+    SELECT "c", q.a1
+    FROM "arithmetic_space"
+    INNER JOIN
+        (SELECT "b" AS b1, "a" AS a1 FROM "arithmetic_space2") AS q
+    ON "arithmetic_space"."c" = q.a1
+]], {} })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "arithmetic_space.c", type = "integer" },
+        { name = "Q.A1", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        {1, 1}, {1, 1}, {1, 1}, {1, 1}, {1, 1}, {1, 1}, {1, 1}, {1, 1}
+    })
+end
+
+
+groupby_queries.test_with_join3 = function ()
+    local api = cluster:server("api-1").net_box
+    local r, err = api:call("sbroad.execute", { [[
+    SELECT r."i", q."b"
+    FROM (SELECT "a" AS "i" FROM "arithmetic_space2" GROUP BY "a") AS r
+    INNER JOIN
+        (SELECT "c", "b" FROM "arithmetic_space" GROUP BY "c", "b") AS q
+    ON r."i" = q."b"
+    GROUP BY r."i", q."b"
+]], {} })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "i", type = "integer" },
+        { name = "b", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1, 1 },
+        { 2, 2 },
+    })
+
+    -- without GROUP BY
+    r, err = api:call("sbroad.execute", { [[
+    SELECT r."i", q."b"
+    FROM (SELECT "a" AS "i" FROM "arithmetic_space2") AS r
+    INNER JOIN
+        (SELECT "c", "b" FROM "arithmetic_space") AS q
+    ON r."i" = q."b"
+]], {} })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "R.i", type = "integer" },
+        { name = "Q.b", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        {2, 2}, {2, 2}, {1, 1}, {1, 1}
+    })
+
+end
+
+
+groupby_queries.test_with_UNION = function ()
+    local api = cluster:server("api-1").net_box
+    local r, err = api:call("sbroad.execute", {
+        [[SELECT "a" FROM "arithmetic_space" GROUP BY "a" UNION ALL SELECT "a" FROM "arithmetic_space2"]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "a", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1 },
+        { 2 },
+        { 2 },
+        { 2 },
+        { 1 },
+        { 1 },
+    })
+
+    r, err = api:call("sbroad.execute", {
+        [[
+            SELECT "a" FROM "arithmetic_space" GROUP BY "a" UNION ALL SELECT "a" FROM "arithmetic_space2" GROUP BY "a"
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "a", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1 },
+        { 2 },
+        { 1 },
+        { 2 },
+    })
+
+
+    r, err = api:call("sbroad.execute", {
+        [[
+        SELECT "a" FROM (
+        SELECT "a" FROM "arithmetic_space" GROUP BY "a" UNION ALL SELECT "a" FROM "arithmetic_space2" GROUP BY "a"
+        ) GROUP BY "a"]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "a", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1 },
+        { 2 },
+    })
+end
+
+groupby_queries.test_with_EXCEPT = function ()
+    local api = cluster:server("api-1").net_box
+    local r, err = api:call("sbroad.execute", {
+        [[
+        SELECT "b" FROM "arithmetic_space" GROUP BY "b"
+        EXCEPT
+        SELECT "b" FROM "arithmetic_space2"
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "b", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 3 },
+    })
+
+    r, err = api:call("sbroad.execute", {
+        [[
+        SELECT * FROM (
+            SELECT "a", "b" FROM "arithmetic_space" GROUP BY "a", "b"
+            UNION ALL SELECT * FROM (
+            SELECT "c", "d" FROM "arithmetic_space"
+            EXCEPT
+            SELECT "c", "d" FROM "arithmetic_space2" GROUP BY "c", "d")
+        ) GROUP BY "a", "b"
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "a", type = "integer" },
+        { name = "b", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1, 1 },
+        { 1, 2 },
+        { 2, 3 },
+    })
+
+    r, err = api:call("sbroad.execute", {
+        [[
+        SELECT "b" FROM "arithmetic_space"
+        EXCEPT
+        SELECT "b" FROM "arithmetic_space2"
+        GROUP BY "b"
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "b", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 3 },
+    })
+end
+
+groupby_queries.test_with_subquery_1 = function ()
+    local api = cluster:server("api-1").net_box
+
+    -- with GROUP BY
+    local r, err = api:call("sbroad.execute", {
+        [[
+        SELECT * FROM (
+            SELECT "a", "b" FROM "arithmetic_space2"
+            GROUP BY "a", "b"
+        )
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "a", type = "integer" },
+        { name = "b", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 2, 1 },
+        { 2, 2 },
+        { 1, 1 },
+    })
+
+    -- without GROUP BY
+    r, err = api:call("sbroad.execute", {
+        [[
+        SELECT * FROM (
+            SELECT "a", "b" FROM "arithmetic_space2"
+        )
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "a", type = "integer" },
+        { name = "b", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 2, 1 },
+        { 2, 2 },
+        { 1, 1 },
+        { 1, 1 },
+    })
+end
+
+
+groupby_queries.test_with_subquery_2 = function ()
+    local api = cluster:server("api-1").net_box
+    local r, err = api:call("sbroad.execute", {
+        [[
+            SELECT cast("number_col" AS integer) AS k FROM "arithmetic_space" GROUP BY "number_col"
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "K", type = "integer" },
+    })
+    t.assert_items_equals(r.rows, {
+        { 2 },
+        { 2 },
+        { 3 },
+    })
+
+    r, err = api:call("sbroad.execute", {
+        [[
+        SELECT "f" FROM "arithmetic_space2"
+        WHERE "id" in (SELECT cast("number_col" AS integer) FROM "arithmetic_space" GROUP BY "number_col")
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "f", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 2 },
+        { 2 },
+    })
+
+    r, err = api:call("sbroad.execute", {
+        [[
+        SELECT "f" FROM "arithmetic_space2"
+        WHERE "id" in (SELECT cast("number_col" AS integer) FROM "arithmetic_space" GROUP BY "number_col")
+        GROUP BY "f"
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "f", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 2 },
+    })
+end
+
+groupby_queries.test_with_subquery_3 = function ()
+    local api = cluster:server("api-1").net_box
+
+    local r, err = api:call("sbroad.execute", {
+        [[
+        SELECT "b", "string_col" FROM
+        (SELECT "b", "string_col" FROM "arithmetic_space2" GROUP BY "b", "string_col") AS t1
+        INNER JOIN
+        (SELECT "id" FROM "testing_space" WHERE "id" in (SELECT "a" FROM "arithmetic_space" GROUP BY "a")) AS t2
+        on t2."id" = t1."b"
+        WHERE "b" in (SELECT "c" FROM "arithmetic_space" GROUP BY "c")
+        GROUP BY "b", "string_col"
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "b", type = "integer" },
+        { name = "string_col", type = "string" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1, "a" },
+        { 1, "b" },
+    })
+end
+
+groupby_queries.test_complex_1 = function ()
+    local api = cluster:server("api-1").net_box
+
+    local r, err = api:call("sbroad.execute", {
+        [[
+        SELECT * FROM (
+            SELECT "b" FROM "arithmetic_space"
+            WHERE "a" in
+                (SELECT "a" FROM "arithmetic_space2" WHERE "a" in
+                    (SELECT "b" FROM "arithmetic_space" GROUP BY "b")
+                GROUP BY "a")
+            GROUP BY "b"
+            UNION ALL SELECT * FROM (
+                SELECT "b" FROM "arithmetic_space2"
+                EXCEPT
+                SELECT "a" FROM "arithmetic_space"
+            )
+        )
+        ]], {}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "b", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1 },
+        { 3 },
+        { 2 }
+    })
+end
+
+groupby_queries.test_complex_2 = function ()
+    local api = cluster:server("api-1").net_box
+
+    local r, err = api:call("sbroad.execute", {
+        [[
+        SELECT * FROM (
+            SELECT "b" FROM "arithmetic_space"
+            WHERE "c" in
+                (SELECT "id" FROM "arithmetic_space2" WHERE "id" in
+                    (SELECT "b" FROM "arithmetic_space" GROUP BY "b")
+                GROUP BY "id")
+            GROUP BY "b"
+            UNION ALL
+            SELECT * FROM (
+                SELECT "c" FROM "arithmetic_space2"
+                WHERE "id" = ? or "b" = ?
+                GROUP BY "c"
+                EXCEPT
+                (SELECT "a" FROM "arithmetic_space" GROUP BY "a"))
+        )
+        ]], {2, 1}
+    })
+
+    t.assert_equals(err, nil)
+    t.assert_equals(r.metadata, {
+        { name = "b", type = "integer" },
+    })
+
+    t.assert_items_equals(r.rows, {
+        { 1 },
+        { 3 },
+        { 2 },
+    })
+end
+
diff --git a/sbroad-core/src/backend/sql/ir.rs b/sbroad-core/src/backend/sql/ir.rs
index 35754c64ded0589156b05e7a923f89dd7b7cb762..4d3b61e01df664b480958dc2d1994f2138fe0b09 100644
--- a/sbroad-core/src/backend/sql/ir.rs
+++ b/sbroad-core/src/backend/sql/ir.rs
@@ -1,6 +1,7 @@
 use ahash::AHashMap;
 use opentelemetry::Context;
 use serde::{Deserialize, Serialize};
+use std::collections::hash_map::IntoIter;
 use std::collections::HashMap;
 use std::fmt::Write as _;
 use tarantool::tlua::{self, Push};
@@ -137,15 +138,17 @@ impl From<PatternWithParams> for String {
     }
 }
 
+#[derive(Debug)]
 pub struct TmpSpaceMap {
     inner: AHashMap<String, TmpSpace>,
 }
 
-impl Iterator for TmpSpaceMap {
-    type Item = TmpSpace;
+impl IntoIterator for TmpSpaceMap {
+    type Item = (String, TmpSpace);
+    type IntoIter = IntoIter<String, TmpSpace>;
 
-    fn next(&mut self) -> Option<Self::Item> {
-        self.inner.drain().next().map(|(_, v)| v)
+    fn into_iter(self) -> Self::IntoIter {
+        self.inner.into_iter()
     }
 }
 
@@ -291,6 +294,7 @@ impl ExecutionPlan {
                             }
                             Node::Relational(rel) => match rel {
                                 Relational::Except { .. } => sql.push_str("EXCEPT"),
+                                Relational::GroupBy { .. } => sql.push_str("GROUP BY"),
                                 Relational::Insert { relation, .. } => {
                                     sql.push_str("INSERT INTO ");
                                     sql.push_str(relation.as_str());
diff --git a/sbroad-core/src/backend/sql/tree.rs b/sbroad-core/src/backend/sql/tree.rs
index 0b945fc315d860ea0e3d824fbdb8b8f4a2b9b838..6ceaba088a07407f43f2f8728f53eff5e22caa9f 100644
--- a/sbroad-core/src/backend/sql/tree.rs
+++ b/sbroad-core/src/backend/sql/tree.rs
@@ -1,9 +1,9 @@
 use ahash::RandomState;
+use serde::{Deserialize, Serialize};
+
 use std::collections::HashMap;
 use std::mem::take;
 
-use serde::{Deserialize, Serialize};
-
 use crate::errors::{Action, Entity, SbroadError};
 use crate::executor::ir::ExecutionPlan;
 use crate::ir::expression::Expression;
@@ -174,6 +174,51 @@ pub struct SyntaxNodes {
     map: HashMap<usize, usize, RandomState>,
 }
 
+#[derive(Debug)]
+pub struct SyntaxIterator<'n> {
+    current: usize,
+    child: usize,
+    nodes: &'n SyntaxNodes,
+}
+
+impl<'n> SyntaxNodes {
+    #[must_use]
+    pub fn iter(&'n self, current: usize) -> SyntaxIterator<'n> {
+        SyntaxIterator {
+            current,
+            child: 0,
+            nodes: self,
+        }
+    }
+}
+
+impl<'n> Iterator for SyntaxIterator<'n> {
+    type Item = usize;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        syntax_next(self).copied()
+    }
+}
+
+fn syntax_next<'nodes>(iter: &mut SyntaxIterator<'nodes>) -> Option<&'nodes usize> {
+    match iter.nodes.arena.get(iter.current) {
+        Some(SyntaxNode { left, right, .. }) => {
+            if iter.child == 0 {
+                iter.child += 1;
+                if let Some(left_id) = left {
+                    return Some(left_id);
+                }
+            }
+            let right_idx = iter.child - 1;
+            if right_idx < right.len() {
+                iter.child += 1;
+                return Some(&right[right_idx]);
+            }
+            None
+        }
+        None => None,
+    }
+}
 impl SyntaxNodes {
     /// Add sub-query syntax node
     ///
@@ -311,16 +356,129 @@ struct Select {
     selection: Option<usize>,
     /// Join syntax node
     join: Option<usize>,
+    /// GroupBy syntax node
+    groupby: Option<usize>,
 }
 
+type NodeAdder = fn(&mut Select, usize, &SyntaxPlan) -> Result<bool, SbroadError>;
 impl Select {
+    fn add_one_of(
+        id: usize,
+        select: &mut Select,
+        sp: &SyntaxPlan,
+        adders: &[NodeAdder],
+    ) -> Result<bool, SbroadError> {
+        for add in adders {
+            if add(select, id, sp)? {
+                return Ok(true);
+            }
+        }
+        Ok(false)
+    }
+
+    fn add_inner_join(
+        select: &mut Select,
+        id: usize,
+        sp: &SyntaxPlan,
+    ) -> Result<bool, SbroadError> {
+        let sn = sp.nodes.get_syntax_node(id)?;
+        let left_id = sn.left_id_or_err()?;
+        let sn_left = sp.nodes.get_syntax_node(left_id)?;
+        let plan_node_left = sp.plan_node_or_err(&sn_left.data)?;
+        if let Node::Relational(Relational::InnerJoin { .. }) = sp.plan_node_or_err(&sn.data)? {
+            select.join = Some(id);
+            if let Node::Relational(
+                Relational::ScanRelation { .. }
+                | Relational::ScanSubQuery { .. }
+                | Relational::Motion { .. },
+            ) = plan_node_left
+            {
+                select.scan = left_id;
+                return Ok(true);
+            }
+            return Err(SbroadError::Invalid(
+                Entity::SyntaxPlan,
+                Some(format!(
+                    "Expected a scan or motion after InnerJoin. Got: {plan_node_left:?}"
+                )),
+            ));
+        }
+        Ok(false)
+    }
+
+    fn add_selection(select: &mut Select, id: usize, sp: &SyntaxPlan) -> Result<bool, SbroadError> {
+        let sn = sp.nodes.get_syntax_node(id)?;
+        if let Node::Relational(Relational::Selection { .. }) = sp.plan_node_or_err(&sn.data)? {
+            select.selection = Some(id);
+            let left_id = sn.left_id_or_err()?;
+            let sn_left = sp.nodes.get_syntax_node(left_id)?;
+            let plan_node_left = sp.plan_node_or_err(&sn_left.data)?;
+            if let Node::Relational(
+                Relational::ScanRelation { .. } | Relational::ScanSubQuery { .. },
+            ) = plan_node_left
+            {
+                select.scan = left_id;
+                return Ok(true);
+            }
+            if !Select::add_one_of(left_id, select, sp, &[Select::add_inner_join])? {
+                return Err(SbroadError::Invalid(
+                    Entity::SyntaxPlan,
+                    Some(format!(
+                        "expected InnerJoin or Scan after Selection. Got {plan_node_left:?}"
+                    )),
+                ));
+            }
+            Ok(true)
+        } else {
+            Ok(false)
+        }
+    }
+
+    fn add_groupby(select: &mut Select, id: usize, sp: &SyntaxPlan) -> Result<bool, SbroadError> {
+        let sn = sp.nodes.get_syntax_node(id)?;
+        if let Node::Relational(Relational::GroupBy { .. }) = sp.plan_node_or_err(&sn.data)? {
+            select.groupby = Some(id);
+            let left_id = sn.left_id_or_err()?;
+            let sn_left = sp.nodes.get_syntax_node(left_id)?;
+            let plan_node_left = sp.plan_node_or_err(&sn_left.data)?;
+            if let Node::Relational(
+                Relational::ScanRelation { .. }
+                | Relational::ScanSubQuery { .. }
+                | Relational::Motion { .. },
+            ) = plan_node_left
+            {
+                select.scan = left_id;
+                return Ok(true);
+            }
+            if !Select::add_one_of(
+                left_id,
+                select,
+                sp,
+                &[Select::add_selection, Select::add_inner_join],
+            )? {
+                return Err(SbroadError::Invalid(
+                    Entity::SyntaxPlan,
+                    Some(format!(
+                        "expected Scan or InnerJoin, or Selection after GroupBy. Got {plan_node_left:?}"
+                    ))));
+            }
+            Ok(true)
+        } else {
+            Ok(false)
+        }
+    }
+
     /// Constructor.
     ///
-    /// There are four valid combinations of the `SELECT` command:
+    /// There are several valid combinations of the `SELECT` command:
     /// - projection -> selection -> join -> scan
+    /// - projection -> groupby -> selection -> join -> scan
     /// - projection -> join -> scan
+    /// - projection -> groupby -> join -> scan
     /// - projection -> selection -> scan
+    /// - projection -> groupby -> selection -> scan
     /// - projection -> scan
+    /// - projection -> groupby -> scan
     fn new(
         sp: &SyntaxPlan,
         parent: Option<usize>,
@@ -328,122 +486,45 @@ impl Select {
         id: usize,
     ) -> Result<Option<Select>, SbroadError> {
         let sn = sp.nodes.get_syntax_node(id)?;
-        // Expecting projection
-        // projection -> ...
         if let Some(Node::Relational(Relational::Projection { .. })) = sp.get_plan_node(&sn.data)? {
-        } else {
-            return Ok(None);
-        }
-        let left_id = sn.left_id_or_err()?;
-        let sn_left = sp.nodes.get_syntax_node(left_id)?;
-        let plan_node_left = sp.plan_node_or_err(&sn_left.data)?;
-
-        match plan_node_left {
-            // Expecting projection over selection
-            // projection -> selection -> ...
-            Node::Relational(Relational::Selection { .. }) => {
-                let next_left_id = sn_left.left_id_or_err()?;
-                let sn_next_left = sp.nodes.get_syntax_node(next_left_id)?;
-                let plan_node_next_left = sp.plan_node_or_err(&sn_next_left.data)?;
-
-                match plan_node_next_left {
-                    // Expecting selection over join
-                    // projection -> selection -> join -> ...
-                    Node::Relational(Relational::InnerJoin { .. }) => {
-                        let next_next_left_id = sn_next_left.left_id_or_err()?;
-                        let sn_next_next_left = sp.nodes.get_syntax_node(next_next_left_id)?;
-                        let plan_node_next_next_left =
-                            sp.plan_node_or_err(&sn_next_next_left.data)?;
-
-                        // Expecting join over scan
-                        // projection -> selection -> join -> scan
-                        if let Node::Relational(
-                            Relational::ScanRelation { .. } | Relational::ScanSubQuery { .. },
-                        ) = plan_node_next_next_left
-                        {
-                            let select = Select {
-                                parent,
-                                branch,
-                                proj: id,
-                                scan: next_next_left_id,
-                                selection: Some(left_id),
-                                join: Some(next_left_id),
-                            };
-                            return Ok(Some(select));
-                        }
-                    }
-                    // Expecting selection over scan
-                    // projection -> selection -> scan
-                    Node::Relational(
-                        Relational::ScanRelation { .. } | Relational::ScanSubQuery { .. },
-                    ) => {
-                        return Ok(Some(Select {
-                            parent,
-                            branch,
-                            proj: id,
-                            scan: next_left_id,
-                            selection: Some(left_id),
-                            join: None,
-                        }));
-                    }
-                    _ => {
-                        return Err(SbroadError::Invalid(
-                            Entity::Plan,
-                            Some(
-                                "current node must be InnerJoin, ScanSubQuery or ScanRelation"
-                                    .into(),
-                            ),
-                        ));
-                    }
-                }
-            }
-            // Expecting projection over scan
-            // projection -> scan
-            Node::Relational(Relational::ScanRelation { .. } | Relational::ScanSubQuery { .. }) => {
-                return Ok(Some(Select {
-                    parent,
-                    branch,
-                    proj: id,
-                    scan: left_id,
-                    selection: None,
-                    join: None,
-                }));
-            }
-            // Expecting projection over inner join
-            // projection -> join -> ...
-            Node::Relational(Relational::InnerJoin { .. }) => {
-                let next_left_id = sn_left.left_id_or_err()?;
-                let sn_next_left = sp.nodes.get_syntax_node(next_left_id)?;
-                let plan_node_next_left = sp.plan_node_or_err(&sn_next_left.data)?;
-
-                // Expecting join over scan
-                // projection -> join -> scan
-                if let Node::Relational(
-                    Relational::ScanRelation { .. } | Relational::ScanSubQuery { .. },
-                ) = plan_node_next_left
-                {
-                    let select = Select {
-                        parent,
-                        branch,
-                        proj: id,
-                        scan: next_left_id,
-                        selection: None,
-                        join: Some(left_id),
-                    };
-                    return Ok(Some(select));
-                }
-            }
-            _ => {
+            let mut select = Select {
+                parent,
+                branch,
+                proj: id,
+                scan: 0,
+                selection: None,
+                join: None,
+                groupby: None,
+            };
+            let left_id = sn.left_id_or_err()?;
+            let sn_left = sp.nodes.get_syntax_node(left_id)?;
+            let plan_node_left = sp.plan_node_or_err(&sn_left.data)?;
+            if let Node::Relational(
+                Relational::ScanRelation { .. } | Relational::ScanSubQuery { .. },
+            ) = plan_node_left
+            {
+                select.scan = left_id;
+            } else if !Select::add_one_of(
+                left_id,
+                &mut select,
+                sp,
+                &[
+                    Select::add_selection,
+                    Select::add_inner_join,
+                    Select::add_groupby,
+                ],
+            )? {
                 return Err(SbroadError::Invalid(
-                    Entity::Plan,
-                    Some("current node must be Selection, ScanRelation, or InnerJoin".into()),
-                ))
+                    Entity::SyntaxPlan,
+                    Some(format!(
+                        "expected Scan, InnerJoin, Selection, GroupBy after Projection. Got {plan_node_left:?}"
+                    ))));
+            }
+            if select.scan != 0 {
+                return Ok(Some(select));
             }
         }
-        Err(SbroadError::Invalid(
-            Entity::Plan,
-            Some("invalid combination of the select command".into()),
-        ))
+        Ok(None)
     }
 }
 
@@ -597,6 +678,27 @@ impl<'p> SyntaxPlan<'p> {
                     Err(SbroadError::Invalid(Entity::Node, None))
                 }
                 Relational::ScanSubQuery { .. } => self.nodes.add_sq(rel, id),
+                Relational::GroupBy {
+                    children, gr_cols, ..
+                } => {
+                    let left_id = *children.first().ok_or_else(|| {
+                        SbroadError::UnexpectedNumberOfValues("GroupBy has no children.".into())
+                    })?;
+                    let mut right: Vec<usize> = Vec::with_capacity(gr_cols.len() * 2);
+                    if let Some((last, other)) = gr_cols.split_last() {
+                        for col_id in other {
+                            right.push(self.nodes.get_syntax_node_id(*col_id)?);
+                            right.push(self.nodes.push_syntax_node(SyntaxNode::new_comma()));
+                        }
+                        right.push(self.nodes.get_syntax_node_id(*last)?);
+                    }
+                    let sn = SyntaxNode::new_pointer(
+                        id,
+                        Some(self.nodes.get_syntax_node_id(left_id)?),
+                        right,
+                    );
+                    Ok(self.nodes.push_syntax_node(sn))
+                }
                 Relational::Selection {
                     children, filter, ..
                 } => {
@@ -659,7 +761,10 @@ impl<'p> SyntaxPlan<'p> {
                         ]);
 
                         if let Some(name) = vtable_alias {
-                            children.push(self.nodes.push_syntax_node(SyntaxNode::new_alias(name)));
+                            if !name.is_empty() {
+                                children
+                                    .push(self.nodes.push_syntax_node(SyntaxNode::new_alias(name)));
+                            }
                         }
                         let sn = SyntaxNode::new_pointer(id, None, children);
                         return Ok(self.nodes.push_syntax_node(sn));
@@ -939,7 +1044,14 @@ impl<'p> SyntaxPlan<'p> {
     fn gather_selects(&self) -> Result<Option<Vec<Select>>, SbroadError> {
         let mut selects: Vec<Select> = Vec::new();
         let top = self.get_top()?;
-        for (pos, node) in self.nodes.arena.iter().enumerate() {
+        let mut dfs = PostOrder::with_capacity(
+            |node| self.nodes.iter(node),
+            self.plan.get_ir_plan().nodes.len(),
+        );
+        dfs.populate_nodes(top);
+        let nodes = dfs.take_nodes();
+        for (_, pos) in nodes {
+            let node = self.nodes.get_syntax_node(pos)?;
             if pos == top {
                 let select = Select::new(self, None, None, pos)?;
                 if let Some(s) = select {
@@ -1036,59 +1148,31 @@ impl<'p> SyntaxPlan<'p> {
         Ok(sp)
     }
 
-    /// Reorder `SELECT` chain to:
-    ///
-    /// parent (if some) -branch-> selection (if some) -left->
-    /// join (if some) -left-> scan -left-> projection
-    ///
-    /// # Errors
-    /// - select nodes (parent, scan, projection, selection) are invalid
     fn reorder(&mut self, select: &Select) -> Result<(), SbroadError> {
         // Move projection under scan.
         let mut proj = self.nodes.get_mut_syntax_node(select.proj)?;
+        let new_top = proj.left.ok_or_else(|| {
+            SbroadError::Invalid(
+                Entity::SyntaxPlan,
+                Some("Proj syntax node does not have left child!".into()),
+            )
+        })?;
         proj.left = None;
         let mut scan = self.nodes.get_mut_syntax_node(select.scan)?;
         scan.left = Some(select.proj);
-        let mut top = select.scan;
-
-        if let Some(id) = select.selection {
-            let mut selection = self.nodes.get_mut_syntax_node(id)?;
-
-            match select.join {
-                // Try to move join under selection.
-                Some(join_id) => {
-                    selection.left = Some(join_id);
-                    // Try to move scan under join.
-                    let mut join = self.nodes.get_mut_syntax_node(join_id)?;
-                    join.left = Some(top);
-                }
-                // Try to move scan under selection.
-                None => {
-                    selection.left = Some(top);
-                }
-            }
-            top = id;
-        } else {
-            // Try to move scan under join.
-            if let Some(join_id) = select.join {
-                let mut join = self.nodes.get_mut_syntax_node(join_id)?;
-                join.left = Some(top);
-                top = join_id;
-            }
-        }
 
         // Try to move new top under parent.
         if let Some(id) = select.parent {
             let mut parent = self.nodes.get_mut_syntax_node(id)?;
             match select.branch {
                 Some(Branch::Left) => {
-                    parent.left = Some(top);
+                    parent.left = Some(new_top);
                 }
                 Some(Branch::Right) => {
                     let mut found: bool = false;
                     for child in &mut parent.right {
                         if child == &select.proj {
-                            *child = top;
+                            *child = new_top;
                             found = true;
                         }
                     }
@@ -1113,7 +1197,7 @@ impl<'p> SyntaxPlan<'p> {
 
         // Update the syntax plan top if it was current projection
         if self.get_top()? == select.proj {
-            self.set_top(top)?;
+            self.set_top(new_top)?;
         }
 
         Ok(())
diff --git a/sbroad-core/src/executor/bucket.rs b/sbroad-core/src/executor/bucket.rs
index 85b93a474fbd59885e034f6977dd96ad959a0677..39a832057854f0498873d2c5a0f3fddb93097789 100644
--- a/sbroad-core/src/executor/bucket.rs
+++ b/sbroad-core/src/executor/bucket.rs
@@ -262,6 +262,9 @@ where
                 | Relational::Projection {
                     children, output, ..
                 }
+                | Relational::GroupBy {
+                    children, output, ..
+                }
                 | Relational::ScanSubQuery {
                     children, output, ..
                 } => {
diff --git a/sbroad-core/src/executor/bucket/tests.rs b/sbroad-core/src/executor/bucket/tests.rs
index af7d43fd410ece1ae82e23c175e03170efa03641..8a3df1bfa7875f871fb7b9cfc2df147748ed27de 100644
--- a/sbroad-core/src/executor/bucket/tests.rs
+++ b/sbroad-core/src/executor/bucket/tests.rs
@@ -4,6 +4,7 @@ use std::collections::HashSet;
 use crate::executor::bucket::Buckets;
 use crate::executor::engine::mock::RouterRuntimeMock;
 use crate::executor::engine::Coordinator;
+
 use crate::executor::Query;
 use crate::ir::helpers::RepeatableState;
 use crate::ir::value::Value;
diff --git a/sbroad-core/src/executor/ir.rs b/sbroad-core/src/executor/ir.rs
index 99eee4361f48f6d33d5be4baa6d9f8b4ba0f8c09..89bdb826ea316c060053e89d96e7bb83798b2a3b 100644
--- a/sbroad-core/src/executor/ir.rs
+++ b/sbroad-core/src/executor/ir.rs
@@ -143,6 +143,7 @@ impl ExecutionPlan {
         match rel {
             Relational::ScanSubQuery { .. } => self.get_subquery_child(*top_id),
             Relational::Except { .. }
+            | Relational::GroupBy { .. }
             | Relational::InnerJoin { .. }
             | Relational::Projection { .. }
             | Relational::ScanRelation { .. }
@@ -312,6 +313,21 @@ impl ExecutionPlan {
                         }
                     }
 
+                    if let Relational::GroupBy { gr_cols, .. } = rel {
+                        let mut new_cols: Vec<usize> = Vec::with_capacity(gr_cols.len());
+                        for col_id in gr_cols.iter() {
+                            let new_col_id = *translation.get(col_id).ok_or_else(|| {
+                                SbroadError::NotFound(
+                                    Entity::Node,
+                                    format!("grouping column {col_id} in translation map"),
+                                )
+                            })?;
+                            new_plan.replace_parent_in_subtree(new_col_id, None, Some(next_id))?;
+                            new_cols.push(new_col_id);
+                        }
+                        *gr_cols = new_cols;
+                    }
+
                     let output = rel.output();
                     *rel.mut_output() = *translation.get(&output).ok_or_else(|| {
                         SbroadError::NotFound(
diff --git a/sbroad-core/src/executor/tests.rs b/sbroad-core/src/executor/tests.rs
index e474d55af3f52a83081a4752abc28ab9b45b52fb..47fc7cde8f84e5f24441165b5fe5bc7475020da9 100644
--- a/sbroad-core/src/executor/tests.rs
+++ b/sbroad-core/src/executor/tests.rs
@@ -1,12 +1,14 @@
 use pretty_assertions::assert_eq;
 
 use crate::backend::sql::ir::PatternWithParams;
+
 use crate::executor::engine::mock::RouterRuntimeMock;
 use crate::executor::result::ProducerResult;
 use crate::executor::vtable::VirtualTable;
 use crate::ir::operator::Relational;
 use crate::ir::relation::{Column, ColumnRole, Type};
 use crate::ir::transformation::redistribution::{DataGeneration, MotionPolicy};
+
 use crate::ir::value::{EncodedValue, Value};
 
 use super::*;
@@ -1521,6 +1523,75 @@ pub(crate) fn broadcast_check(sql: &str, pattern: &str, params: Vec<Value>) {
     assert_eq!(expected, result);
 }
 
+#[test]
+fn groupby_linker_test() {
+    let sql = r#"SELECT t1."id" as "ii" FROM "test_space" as t1 group by t1."id""#;
+
+    let coordinator = RouterRuntimeMock::new();
+
+    let mut query = Query::new(&coordinator, sql, vec![]).unwrap();
+
+    let motion_id = *query
+        .exec_plan
+        .get_ir_plan()
+        .clone_slices()
+        .slice(0)
+        .unwrap()
+        .position(0)
+        .unwrap();
+    let top_id = query.exec_plan.get_motion_subtree_root(motion_id).unwrap();
+    if Buckets::All != query.bucket_discovery(top_id).unwrap() {
+        panic!("Expected Buckets::All for local groupby")
+    };
+    let mut virtual_t1 = VirtualTable::new();
+    virtual_t1.add_column(Column {
+        name: "id".into(),
+        r#type: Type::Integer,
+        role: ColumnRole::User,
+    });
+
+    let mut buckets: Vec<u64> = vec![];
+    let tuples: Vec<Vec<Value>> = vec![vec![Value::from(1_u64)], vec![Value::from(2_u64)]];
+
+    for tuple in tuples.iter() {
+        virtual_t1.add_tuple(tuple.clone());
+        let mut ref_tuple: Vec<&Value> = Vec::with_capacity(tuple.len());
+        for v in tuple.iter() {
+            ref_tuple.push(v);
+        }
+        buckets.push(coordinator.determine_bucket_id(&ref_tuple));
+    }
+
+    virtual_t1.set_alias("").unwrap();
+
+    query.coordinator.add_virtual_table(motion_id, virtual_t1);
+
+    let result = *query
+        .dispatch()
+        .unwrap()
+        .downcast::<ProducerResult>()
+        .unwrap();
+
+    let mut expected = ProducerResult::new();
+    for buc in buckets {
+        expected.rows.extend(vec![vec![
+            EncodedValue::String(format!("Execute query on a bucket [{buc}]")),
+            EncodedValue::String(String::from(PatternWithParams::new(
+                format!(
+                    "{} {} {}",
+                    r#"SELECT "id" as "ii" FROM (SELECT"#,
+                    r#""id" FROM "TMP_test_37")"#,
+                    r#"GROUP BY "T1"."id""#,
+                ),
+                vec![],
+            ))),
+        ]]);
+    }
+
+    expected.rows.sort_by_key(|k| k[0].to_string());
+    assert_eq!(expected, result);
+}
+
 #[cfg(test)]
 mod between;
 
diff --git a/sbroad-core/src/executor/tests/subtree.rs b/sbroad-core/src/executor/tests/subtree.rs
index 807dd886ea732824f42269953470c2b3decd0ccb..a50b6592275f08dc8150e1a06cc0ccdc059eb229 100644
--- a/sbroad-core/src/executor/tests/subtree.rs
+++ b/sbroad-core/src/executor/tests/subtree.rs
@@ -65,3 +65,155 @@ fn exec_plan_subtree_test() {
             vec![]
         ));
 }
+
+#[test]
+fn exec_plan_subtree_two_stage_groupby_test() {
+    let sql = r#"SELECT t1."FIRST_NAME" FROM "test_space" as t1 group by t1."FIRST_NAME""#;
+    let coordinator = RouterRuntimeMock::new();
+
+    let mut query = Query::new(&coordinator, sql, vec![]).unwrap();
+    let motion_id = *query
+        .exec_plan
+        .get_ir_plan()
+        .clone_slices()
+        .slice(0)
+        .unwrap()
+        .position(0)
+        .unwrap();
+
+    let mut virtual_table = VirtualTable::new();
+    virtual_table.add_column(Column {
+        name: "FIRST_NAME".into(),
+        r#type: Type::String,
+        role: ColumnRole::User,
+    });
+    virtual_table.set_alias("").unwrap();
+
+    if let MotionPolicy::Segment(key) = get_motion_policy(query.exec_plan.get_ir_plan(), motion_id)
+    {
+        query
+            .reshard_vtable(&mut virtual_table, key, &DataGeneration::None)
+            .unwrap();
+    }
+
+    let mut vtables: HashMap<usize, Rc<VirtualTable>> = HashMap::new();
+    vtables.insert(motion_id, Rc::new(virtual_table));
+
+    let exec_plan = query.get_mut_exec_plan();
+    exec_plan.set_vtables(vtables);
+    let top_id = exec_plan.get_ir_plan().get_top().unwrap();
+    let motion_child_id = exec_plan.get_motion_subtree_root(motion_id).unwrap();
+    if let MotionPolicy::Segment(_) = exec_plan.get_motion_policy(motion_id).unwrap() {
+    } else {
+        panic!("Expected MotionPolicy::Segment for local aggregation stage");
+    };
+
+    // Check groupby local stage
+    let subplan1 = exec_plan.take_subtree(motion_child_id).unwrap();
+    let subplan1_top_id = subplan1.get_ir_plan().get_top().unwrap();
+    let sp = SyntaxPlan::new(&subplan1, subplan1_top_id, Snapshot::Oldest).unwrap();
+    let ordered = OrderedSyntaxNodes::try_from(sp).unwrap();
+    let nodes = ordered.to_syntax_data().unwrap();
+    let (sql, _) = subplan1.to_sql(&nodes, &Buckets::All, "test").unwrap();
+    assert_eq!(
+        sql,
+        PatternWithParams::new(
+            r#"SELECT "T1"."FIRST_NAME" FROM "test_space" as "T1" GROUP BY "T1"."FIRST_NAME""#
+                .to_string(),
+            vec![]
+        )
+    );
+
+    // Check main query
+    let subplan2 = exec_plan.take_subtree(top_id).unwrap();
+    let subplan2_top_id = subplan2.get_ir_plan().get_top().unwrap();
+    let sp = SyntaxPlan::new(&subplan2, subplan2_top_id, Snapshot::Oldest).unwrap();
+    let ordered = OrderedSyntaxNodes::try_from(sp).unwrap();
+    let nodes = ordered.to_syntax_data().unwrap();
+    let (sql, _) = subplan2.to_sql(&nodes, &Buckets::All, "test").unwrap();
+    assert_eq!(
+        sql,
+        PatternWithParams::new(
+            r#"SELECT "FIRST_NAME" FROM (SELECT "FIRST_NAME" FROM "TMP_test_6") GROUP BY "FIRST_NAME""#.to_string(),
+            vec![]
+        ));
+}
+
+#[test]
+fn exec_plan_subtree_two_stage_groupby_test_2() {
+    let sql = r#"SELECT t1."FIRST_NAME" as i1, t1."sys_op" as i2 FROM "test_space" as t1 group by t1."FIRST_NAME", t1."sys_op", t1."sysFrom""#;
+    let coordinator = RouterRuntimeMock::new();
+
+    let mut query = Query::new(&coordinator, sql, vec![]).unwrap();
+    let motion_id = *query
+        .exec_plan
+        .get_ir_plan()
+        .clone_slices()
+        .slice(0)
+        .unwrap()
+        .position(0)
+        .unwrap();
+    let mut virtual_table = VirtualTable::new();
+    virtual_table.add_column(Column {
+        name: "FIRST_NAME".into(),
+        r#type: Type::String,
+        role: ColumnRole::User,
+    });
+    virtual_table.add_column(Column {
+        name: "sys_op".into(),
+        r#type: Type::Integer,
+        role: ColumnRole::User,
+    });
+    virtual_table.add_column(Column {
+        name: "sysFrom".into(),
+        r#type: Type::Integer,
+        role: ColumnRole::User,
+    });
+    virtual_table.set_alias("").unwrap();
+    if let MotionPolicy::Segment(key) = get_motion_policy(query.exec_plan.get_ir_plan(), motion_id)
+    {
+        query
+            .reshard_vtable(&mut virtual_table, key, &DataGeneration::None)
+            .unwrap();
+    }
+
+    let mut vtables: HashMap<usize, Rc<VirtualTable>> = HashMap::new();
+    vtables.insert(motion_id, Rc::new(virtual_table));
+
+    let exec_plan = query.get_mut_exec_plan();
+    exec_plan.set_vtables(vtables);
+    let top_id = exec_plan.get_ir_plan().get_top().unwrap();
+    let motion_child_id = exec_plan.get_motion_subtree_root(motion_id).unwrap();
+
+    // Check groupby local stage
+    let subplan1 = exec_plan.take_subtree(motion_child_id).unwrap();
+    let subplan1_top_id = subplan1.get_ir_plan().get_top().unwrap();
+    let sp = SyntaxPlan::new(&subplan1, subplan1_top_id, Snapshot::Oldest).unwrap();
+    let ordered = OrderedSyntaxNodes::try_from(sp).unwrap();
+    let nodes = ordered.to_syntax_data().unwrap();
+    let (sql, _) = subplan1.to_sql(&nodes, &Buckets::All, "test").unwrap();
+    if let MotionPolicy::Segment(_) = exec_plan.get_motion_policy(motion_id).unwrap() {
+    } else {
+        panic!("Expected MotionPolicy::Segment for local aggregation stage");
+    };
+    assert_eq!(
+         sql,
+         PatternWithParams::new(
+             r#"SELECT "T1"."FIRST_NAME", "T1"."sys_op", "T1"."sysFrom" FROM "test_space" as "T1" GROUP BY "T1"."FIRST_NAME", "T1"."sys_op", "T1"."sysFrom""#.to_string(),
+             vec![]
+         ));
+
+    // Check main query
+    let subplan2 = exec_plan.take_subtree(top_id).unwrap();
+    let subplan2_top_id = subplan2.get_ir_plan().get_top().unwrap();
+    let sp = SyntaxPlan::new(&subplan2, subplan2_top_id, Snapshot::Oldest).unwrap();
+    let ordered = OrderedSyntaxNodes::try_from(sp).unwrap();
+    let nodes = ordered.to_syntax_data().unwrap();
+    let (sql, _) = subplan2.to_sql(&nodes, &Buckets::All, "test").unwrap();
+    assert_eq!(
+        sql,
+        PatternWithParams::new(
+            r#"SELECT "FIRST_NAME" as "I1", "sys_op" as "I2" FROM (SELECT "FIRST_NAME","sys_op","sysFrom" FROM "TMP_test_12") GROUP BY "FIRST_NAME", "sys_op", "sysFrom""#.to_string(),
+            vec![]
+        ));
+}
diff --git a/sbroad-core/src/executor/vtable.rs b/sbroad-core/src/executor/vtable.rs
index b1b2a8de065ef86efb9e8014bb9265fb0376bf19..d4fb28c795eded8c56a89071420321c352976da7 100644
--- a/sbroad-core/src/executor/vtable.rs
+++ b/sbroad-core/src/executor/vtable.rs
@@ -1,4 +1,5 @@
 use std::collections::{HashMap, HashSet};
+use std::fmt::{Display, Formatter};
 use std::rc::Rc;
 use std::vec;
 
@@ -59,6 +60,19 @@ impl Default for VirtualTable {
     }
 }
 
+impl Display for VirtualTable {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        for col in &self.columns {
+            write!(f, "{col:?}, ")?;
+        }
+        writeln!(f)?;
+        for row in &self.tuples {
+            writeln!(f, "{row:?}")?;
+        }
+        writeln!(f)
+    }
+}
+
 impl VirtualTable {
     #[must_use]
     pub fn new() -> Self {
@@ -195,13 +209,6 @@ impl VirtualTable {
     /// # Errors
     /// - Try to set an empty alias name to the virtual table.
     pub fn set_alias(&mut self, name: &str) -> Result<(), SbroadError> {
-        if name.is_empty() {
-            return Err(SbroadError::Invalid(
-                Entity::Value,
-                Some("can't set empty alias for virtual table".into()),
-            ));
-        }
-
         self.name = Some(String::from(name));
         Ok(())
     }
diff --git a/sbroad-core/src/frontend/sql.rs b/sbroad-core/src/frontend/sql.rs
index 1cb30778ab1baaa40bc50af917614c1449a46e5b..9fa9511f354eb08c76bfdc769bd9345cd3c071c8 100644
--- a/sbroad-core/src/frontend/sql.rs
+++ b/sbroad-core/src/frontend/sql.rs
@@ -87,6 +87,7 @@ impl Ast for AbstractSyntaxTree {
 
             // Update parent's node children list
             ast.nodes.add_child(stack_node.parent, node)?;
+
             // Clean parent values (only leafs should contain data)
             if let Some(parent) = stack_node.parent {
                 ast.nodes.update_value(parent, None)?;
@@ -128,6 +129,10 @@ impl Ast for AbstractSyntaxTree {
         let mut rows: HashSet<usize> = HashSet::with_capacity(self.nodes.next_id());
         let mut col_idx: usize = 0;
 
+        let mut groupby_nodes: Vec<usize> = Vec::new();
+        let mut scan_nodes: Vec<usize> = Vec::new();
+        let mut sq_nodes: Vec<usize> = Vec::new();
+
         let mut betweens: Vec<Between> = Vec::new();
         let mut arithmetic_expression_ids: Vec<usize> = Vec::new();
 
@@ -239,6 +244,7 @@ impl Ast for AbstractSyntaxTree {
                         let t = metadata.get_table_segment(table)?;
                         plan.add_rel(t);
                         let scan_id = plan.add_scan(&normalize_name_from_sql(table), None)?;
+                        scan_nodes.push(scan_id);
                         map.add(id, scan_id);
                     } else {
                         return Err(SbroadError::Invalid(
@@ -272,6 +278,7 @@ impl Ast for AbstractSyntaxTree {
                         None
                     };
                     let plan_sq_id = plan.add_sub_query(plan_child_id, alias_name.as_deref())?;
+                    sq_nodes.push(plan_sq_id);
                     map.add(id, plan_sq_id);
                 }
                 Type::Reference => {
@@ -726,6 +733,21 @@ impl Ast for AbstractSyntaxTree {
                         ));
                     }
                 }
+                Type::GroupBy => {
+                    if node.children.len() < 2 {
+                        return Err(SbroadError::UnexpectedNumberOfValues(
+                            "Group by must have at least 2 children.".into(),
+                        ));
+                    }
+                    let mut children: Vec<usize> = Vec::with_capacity(node.children.len());
+                    for ast_column_id in &node.children {
+                        let plan_column_id = map.get(*ast_column_id)?;
+                        children.push(plan_column_id);
+                    }
+                    let groupby_id = plan.add_groupby(&children)?;
+                    groupby_nodes.push(groupby_id);
+                    map.add(id, groupby_id);
+                }
                 Type::InnerJoin => {
                     let ast_left_id = node.children.first().ok_or_else(|| {
                         SbroadError::UnexpectedNumberOfValues("Join has no children.".into())
@@ -861,6 +883,9 @@ impl Ast for AbstractSyntaxTree {
                         }
                     }
                     let projection_id = plan.add_proj_internal(plan_child_id, &columns)?;
+                    if let Some(groupby_id) = groupby_nodes.pop() {
+                        plan.add_two_stage_aggregation(groupby_id)?;
+                    }
                     map.add(id, projection_id);
                 }
                 Type::Multiplication | Type::Addition => {
diff --git a/sbroad-core/src/frontend/sql/ast.rs b/sbroad-core/src/frontend/sql/ast.rs
index 8c9afc082508c885c0582cdeafc9fbd19973ef72..00fe9dbd19d0581d223996b1aeb0c073c2746e85 100644
--- a/sbroad-core/src/frontend/sql/ast.rs
+++ b/sbroad-core/src/frontend/sql/ast.rs
@@ -51,6 +51,8 @@ pub enum Type {
     FunctionName,
     Gt,
     GtEq,
+    GroupBy,
+    GroupingElement,
     In,
     InnerJoin,
     Insert,
@@ -130,6 +132,8 @@ impl Type {
             Rule::False => Ok(Type::False),
             Rule::Function => Ok(Type::Function),
             Rule::FunctionName => Ok(Type::FunctionName),
+            Rule::GroupBy => Ok(Type::GroupBy),
+            Rule::GroupingElement => Ok(Type::GroupingElement),
             Rule::Gt => Ok(Type::Gt),
             Rule::GtEq => Ok(Type::GtEq),
             Rule::In => Ok(Type::In),
@@ -266,6 +270,8 @@ impl fmt::Display for Type {
             Type::Value => "Value".to_string(),
             Type::Values => "Values".to_string(),
             Type::ValuesRow => "ValuesRow".to_string(),
+            Type::GroupBy => "GroupBy".to_string(),
+            Type::GroupingElement => "GroupingElement".to_string(),
         };
         write!(f, "{p}")
     }
@@ -335,6 +341,40 @@ impl ParseNodes {
         id
     }
 
+    /// Push `child_id` to the front of `node_id` children
+    ///
+    /// # Errors
+    /// - Failed to get node from arena
+    pub fn push_front_child(&mut self, node_id: usize, child_id: usize) -> Result<(), SbroadError> {
+        let node = self.get_mut_node(node_id)?;
+        node.children.insert(0, child_id);
+        Ok(())
+    }
+
+    /// Push `child_id` to the back of `node_id` children
+    ///
+    /// # Errors
+    /// - Failed to get node from arena
+    pub fn push_back_child(&mut self, node_id: usize, child_id: usize) -> Result<(), SbroadError> {
+        let node = self.get_mut_node(node_id)?;
+        node.children.push(child_id);
+        Ok(())
+    }
+
+    /// Sets node children to given children
+    ///
+    /// # Errors
+    /// - failed to get node from arena
+    pub fn set_children(
+        &mut self,
+        node_id: usize,
+        new_children: Vec<usize>,
+    ) -> Result<(), SbroadError> {
+        let node = self.get_mut_node(node_id)?;
+        node.children = new_children;
+        Ok(())
+    }
+
     /// Get next node id
     #[must_use]
     pub fn next_id(&self) -> usize {
@@ -410,6 +450,22 @@ impl PartialEq for AbstractSyntaxTree {
     }
 }
 
+/// Helper function to extract i-th element of array, when we sure it is safe
+/// But we don't want to panic if future changes break something, so we
+/// bubble out with error.
+///
+/// Supposed to be used only in `transform_select_X` methods!
+#[inline]
+fn get_or_err(arr: &[usize], idx: usize) -> Result<usize, SbroadError> {
+    arr.get(idx)
+        .ok_or_else(|| {
+            SbroadError::UnexpectedNumberOfValues(format!(
+                "AST children array: {arr:?}. Requested index: {idx}"
+            ))
+        })
+        .map(|v| *v)
+}
+
 #[allow(dead_code)]
 impl AbstractSyntaxTree {
     /// Set the top of AST.
@@ -467,6 +523,7 @@ impl AbstractSyntaxTree {
                 3 => self.transform_select_3(*node, &children)?,
                 4 => self.transform_select_4(*node, &children)?,
                 5 => self.transform_select_5(*node, &children)?,
+                6 => self.transform_select_6(*node, &children)?,
                 _ => return Err(SbroadError::Invalid(Entity::AST, None)),
             }
         }
@@ -507,343 +564,195 @@ impl AbstractSyntaxTree {
         Ok(())
     }
 
-    /// Transforms `Select` with `Projection` and `Scan`
-    fn transform_select_2(
-        &mut self,
-        select_id: usize,
-        children: &[usize],
+    fn check<const N: usize, const M: usize>(
+        &self,
+        allowed: &[[Type; N]; M],
+        select_children: &[usize],
     ) -> Result<(), SbroadError> {
-        if children.len() != 2 {
+        let allowed_len = if let Some(seq) = allowed.first() {
+            seq.len()
+        } else {
+            return Err(SbroadError::UnexpectedNumberOfValues(
+                "Expected at least one sequence to check select children".into(),
+            ));
+        };
+        if select_children.len() != allowed_len {
             return Err(SbroadError::UnexpectedNumberOfValues(format!(
-                "expect children list len 2, got {}",
-                children.len()
+                "Expected select {allowed_len} children, got {}",
+                select_children.len()
             )));
         }
-
-        // Check that the second child is `Scan`.
-        let scan_id: usize = *children.get(1).ok_or_else(|| {
-            SbroadError::NotFound(Entity::Node, "from children list with index 1".into())
-        })?;
-        let scan = self.nodes.get_node(scan_id)?;
-        if scan.rule != Type::Scan {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("scan.rule is not Scan type".into()),
-            ));
+        let mut is_match = false;
+        for seq in allowed {
+            let mut all_types_matched = true;
+            for (child, expected_type) in select_children.iter().zip(seq) {
+                let node = self.nodes.get_node(*child)?;
+                if node.rule != *expected_type {
+                    all_types_matched = false;
+                    break;
+                }
+            }
+            if all_types_matched {
+                is_match = true;
+                break;
+            }
         }
-
-        // Check that the first child is `Projection`.
-        let proj_id: usize = *children.first().ok_or_else(|| {
-            SbroadError::UnexpectedNumberOfValues("children list is empty".into())
-        })?;
-        let proj = self.nodes.arena.get_mut(proj_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {proj_id}"),
-            )
-        })?;
-        if proj.rule != Type::Projection {
+        if !is_match {
             return Err(SbroadError::Invalid(
                 Entity::AST,
-                Some("proj.rule is not Projection type".into()),
+                Some("Could not match select children to any expected sequence".into()),
             ));
         }
+        Ok(())
+    }
 
-        // Append `Scan` to the `Projection` children (zero position)
-        proj.children.insert(0, scan_id);
-
-        // Leave `Projection` the only child of `Select`.
-        let mut select = self.nodes.arena.get_mut(select_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {select_id}"),
-            )
-        })?;
-        select.children = vec![proj_id];
-
+    fn transform_select_2(
+        &mut self,
+        select_id: usize,
+        children: &[usize],
+    ) -> Result<(), SbroadError> {
+        let allowed = [[Type::Projection, Type::Scan]];
+        self.check(&allowed, children)?;
+        self.nodes
+            .push_front_child(get_or_err(children, 0)?, get_or_err(children, 1)?)?;
+        self.nodes.set_children(select_id, vec![children[0]])?;
         Ok(())
     }
 
-    /// Transforms `Select` with `Projection`, `Scan` and `Selection`.
     fn transform_select_3(
         &mut self,
         select_id: usize,
         children: &[usize],
     ) -> Result<(), SbroadError> {
-        if children.len() != 3 {
-            return Err(SbroadError::UnexpectedNumberOfValues(format!(
-                "expect children list len 3, got {}",
-                children.len()
-            )));
-        }
-
-        // Check that the second child is `Scan`.
-        let scan_id: usize = *children.get(1).ok_or_else(|| {
-            SbroadError::NotFound(Entity::Node, "from children list with index 1".into())
-        })?;
-        let scan = self.nodes.get_node(scan_id)?;
-        if scan.rule != Type::Scan {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("scan.rule is not Scan type".into()),
-            ));
-        }
-
-        // Check that the third child is `Selection`.
-        let selection_id: usize = *children.get(2).ok_or_else(|| {
-            SbroadError::NotFound(Entity::Node, "from children list with index 2".into())
-        })?;
-        let selection = self.nodes.arena.get_mut(selection_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {selection_id}"),
-            )
-        })?;
-        if selection.rule != Type::Selection {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("selection.rule is not Selection type".into()),
-            ));
-        }
-
-        // Append `Scan` to the `Selection` children (zero position)
-        selection.children.insert(0, scan_id);
-
-        // Check that the first child is `Projection`.
-        let proj_id: usize = *children.first().ok_or_else(|| {
-            SbroadError::UnexpectedNumberOfValues("children list is empty".into())
-        })?;
-        let proj = self.nodes.arena.get_mut(proj_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {proj_id}"),
-            )
-        })?;
-        if proj.rule != Type::Projection {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("proj.rule is not Projection type".into()),
-            ));
-        }
-
-        // Append `Selection` to the `Projection` children (zero position)
-        proj.children.insert(0, selection_id);
-
-        // Leave `Projection` the only child of `Select`.
-        let mut select = self.nodes.arena.get_mut(select_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {select_id}"),
-            )
-        })?;
-        select.children = vec![proj_id];
-
+        let allowed = [
+            [Type::Projection, Type::Scan, Type::GroupBy],
+            [Type::Projection, Type::Scan, Type::Selection],
+        ];
+        self.check(&allowed, children)?;
+        self.nodes
+            .push_front_child(get_or_err(children, 2)?, get_or_err(children, 1)?)?;
+        self.nodes
+            .push_front_child(get_or_err(children, 0)?, get_or_err(children, 2)?)?;
+        self.nodes.set_children(select_id, vec![children[0]])?;
         Ok(())
     }
 
-    /// Transforms `Select` with `Projection`, `Scan`, `InnerJoin` and `Condition`
     fn transform_select_4(
         &mut self,
         select_id: usize,
         children: &[usize],
     ) -> Result<(), SbroadError> {
-        if children.len() != 4 {
-            return Err(SbroadError::UnexpectedNumberOfValues(format!(
-                "expect children list len 4, got {}",
-                children.len()
-            )));
-        }
-
-        // Check that the second child is `Scan`.
-        let scan_id: usize = *children.get(1).ok_or_else(|| {
-            SbroadError::NotFound(Entity::Node, "from children list with index 1".into())
-        })?;
-        let scan = self.nodes.get_node(scan_id)?;
-        if scan.rule != Type::Scan {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("scan.rule is not Scan type".into()),
-            ));
-        }
-
-        // Check that the forth child is `Condition`.
-        let cond_id: usize = *children.get(3).ok_or_else(|| {
-            SbroadError::NotFound(Entity::Node, "from children list with index 3".into())
-        })?;
-        let cond = self.nodes.get_node(cond_id)?;
-        if cond.rule != Type::Condition {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("cond.rule is not Condition type".into()),
-            ));
-        }
-
-        // Check that the third child is `InnerJoin`.
-        let join_id: usize = *children
-            .get(2)
-            .ok_or_else(|| SbroadError::NotFound(Entity::Node, "with index 2".into()))?;
-        let join = self.nodes.arena.get_mut(join_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {join_id}"),
-            )
-        })?;
-        if join.rule != Type::InnerJoin {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("join.rule is not InnerJoin type".into()),
-            ));
-        }
-
-        // Push `Condition` (forth child) to the end of th `InnerJoin` children list.
-        join.children.push(cond_id);
-
-        // Append `Scan` to the `InnerJoin` children (zero position)
-        join.children.insert(0, scan_id);
-
-        // Check that the first child is `Projection`.
-        let proj_id: usize = *children.first().ok_or_else(|| {
-            SbroadError::UnexpectedNumberOfValues("children list is empty".into())
-        })?;
-        let proj = self.nodes.arena.get_mut(proj_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {proj_id}"),
-            )
-        })?;
-        if proj.rule != Type::Projection {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("proj.rule is not Projection type".into()),
-            ));
+        let allowed = [
+            [
+                Type::Projection,
+                Type::Scan,
+                Type::InnerJoin,
+                Type::Condition,
+            ],
+            [Type::Projection, Type::Scan, Type::Selection, Type::GroupBy],
+        ];
+        self.check(&allowed, children)?;
+        match self.nodes.get_node(children[2])?.rule {
+            Type::InnerJoin => {
+                // insert Scan as first child of InnerJoin
+                self.nodes
+                    .push_front_child(get_or_err(children, 2)?, get_or_err(children, 1)?)?;
+                // push Condition as last child of InnerJoin
+                self.nodes
+                    .push_back_child(get_or_err(children, 2)?, get_or_err(children, 3)?)?;
+                // insert InnerJoin as first child of Projection
+                self.nodes
+                    .push_front_child(get_or_err(children, 0)?, get_or_err(children, 2)?)?;
+            }
+            Type::Selection => {
+                // insert Selection as first child of GroupBy
+                self.nodes
+                    .push_front_child(get_or_err(children, 3)?, get_or_err(children, 2)?)?;
+                // insert Scan as first child of Selection
+                self.nodes
+                    .push_front_child(get_or_err(children, 2)?, get_or_err(children, 1)?)?;
+                // insert GroupBy as first child of Projection
+                self.nodes
+                    .push_front_child(get_or_err(children, 0)?, get_or_err(children, 3)?)?;
+            }
+            _ => return Err(SbroadError::Invalid(Entity::AST, None)),
         }
-
-        // Append `InnerJoin` to the `Projection` children (zero position)
-        proj.children.insert(0, join_id);
-
-        // Leave `Projection` the only child of `Select`.
-        let mut select = self.nodes.arena.get_mut(select_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {select_id}"),
-            )
-        })?;
-        select.children = vec![proj_id];
-
+        self.nodes.set_children(select_id, vec![children[0]])?;
         Ok(())
     }
 
-    /// Transforms `Select` with `Projection`, `Scan`, `InnerJoin`, `Condition` and `Selection`
     fn transform_select_5(
         &mut self,
         select_id: usize,
         children: &[usize],
     ) -> Result<(), SbroadError> {
-        if children.len() != 5 {
-            return Err(SbroadError::UnexpectedNumberOfValues(format!(
-                "expect children list len 5, got {}",
-                children.len()
-            )));
-        }
-
-        // Check that the second child is `Scan`.
-        let scan_id: usize = *children.get(1).ok_or_else(|| {
-            SbroadError::NotFound(Entity::Node, "from children list with index 1".into())
-        })?;
-        let scan = self.nodes.get_node(scan_id)?;
-        if scan.rule != Type::Scan {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("scan.rule is not Scan type".into()),
-            ));
-        }
-
-        // Check that the forth child is `Condition`.
-        let cond_id: usize = *children.get(3).ok_or_else(|| {
-            SbroadError::NotFound(Entity::Node, "from children list with index 3".into())
-        })?;
-        let cond = self.nodes.get_node(cond_id)?;
-        if cond.rule != Type::Condition {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("cond.rule is not Condition type".into()),
-            ));
-        }
-
-        // Check that the third child is `InnerJoin`.
-        let join_id: usize = *children.get(2).ok_or_else(|| {
-            SbroadError::NotFound(Entity::Node, "from children list with index 2".into())
-        })?;
-        let join = self.nodes.arena.get_mut(join_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {join_id}"),
-            )
-        })?;
-        if join.rule != Type::InnerJoin {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("join.rule is not InnerJoin type".into()),
-            ));
-        }
-
-        // Push `Condition` (forth child) to the end of the `InnerJoin` children list.
-        join.children.push(cond_id);
-
-        // Append `Scan` to the `InnerJoin` children (zero position)
-        join.children.insert(0, scan_id);
-
-        // Check that the fifth child is `Selection`.
-        let selection_id: usize = *children.get(4).ok_or_else(|| {
-            SbroadError::NotFound(Entity::Node, "from children list with index 4".into())
-        })?;
-        let selection = self.nodes.arena.get_mut(selection_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {selection_id}"),
-            )
-        })?;
-        if selection.rule != Type::Selection {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("selection.rule is not Selection type".into()),
-            ));
-        }
-
-        // Append `InnerJoin` to the `Selection` children (zero position)
-        selection.children.insert(0, join_id);
-
-        // Check that the first child is `Projection`.
-        let proj_id: usize = *children.first().ok_or_else(|| {
-            SbroadError::UnexpectedNumberOfValues("children list is empty".into())
-        })?;
-        let proj = self.nodes.arena.get_mut(proj_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {proj_id}"),
-            )
-        })?;
-        if proj.rule != Type::Projection {
-            return Err(SbroadError::Invalid(
-                Entity::AST,
-                Some("proj.rule is not Projection type".into()),
-            ));
-        }
-
-        // Append `Selection` to the `Projection` children (zero position)
-        proj.children.insert(0, selection_id);
+        let allowed = [
+            [
+                Type::Projection,
+                Type::Scan,
+                Type::InnerJoin,
+                Type::Condition,
+                Type::Selection,
+            ],
+            [
+                Type::Projection,
+                Type::Scan,
+                Type::InnerJoin,
+                Type::Condition,
+                Type::GroupBy,
+            ],
+        ];
+        self.check(&allowed, children)?;
+        // insert InnerJoin as first child of Selection | GroupBy
+        self.nodes
+            .push_front_child(get_or_err(children, 4)?, get_or_err(children, 2)?)?;
+        // insert Scan as first child of InnerJoin
+        self.nodes
+            .push_front_child(get_or_err(children, 2)?, get_or_err(children, 1)?)?;
+        // push back condition as last child of InnerJoin
+        self.nodes
+            .push_back_child(get_or_err(children, 2)?, get_or_err(children, 3)?)?;
+        // insert GroupBy | Selection as first child of Projection
+        self.nodes
+            .push_front_child(get_or_err(children, 0)?, get_or_err(children, 4)?)?;
+        self.nodes.set_children(select_id, vec![children[0]])?;
+        Ok(())
+    }
 
-        // Leave `Projection` the only child of `Select`.
-        let mut select = self.nodes.arena.get_mut(select_id).ok_or_else(|| {
-            SbroadError::NotFound(
-                Entity::Node,
-                format!("(mutable) from arena with index {select_id}"),
-            )
-        })?;
-        select.children = vec![proj_id];
+    fn transform_select_6(
+        &mut self,
+        select_id: usize,
+        children: &[usize],
+    ) -> Result<(), SbroadError> {
+        let allowed = [[
+            Type::Projection,
+            Type::Scan,
+            Type::InnerJoin,
+            Type::Condition,
+            Type::Selection,
+            Type::GroupBy,
+        ]];
+        self.check(&allowed, children)?;
+        // insert Selection as first child of GroupBy
+        self.nodes
+            .push_front_child(get_or_err(children, 5)?, get_or_err(children, 4)?)?;
+        // insert InnerJoin as first child of Selection
+        self.nodes
+            .push_front_child(get_or_err(children, 4)?, get_or_err(children, 2)?)?;
+        // insert Scan as first child fo InnerJoin
+        self.nodes
+            .push_front_child(get_or_err(children, 2)?, get_or_err(children, 1)?)?;
+        // push back Condition as last child of InnerJoin
+        self.nodes
+            .push_back_child(get_or_err(children, 2)?, get_or_err(children, 3)?)?;
+        // insert GroupBy as first child of Projection
+        self.nodes
+            .push_front_child(get_or_err(children, 0)?, get_or_err(children, 5)?)?;
+        self.nodes.set_children(select_id, vec![children[0]])?;
         Ok(())
     }
 
+    #[allow(clippy::too_many_lines)]
     /// Add aliases to projection columns.
     ///
     /// # Errors
@@ -931,6 +840,7 @@ impl AbstractSyntaxTree {
         Ok(())
     }
 
+    #[allow(clippy::too_many_lines)]
     /// Map references to the corresponding relational nodes.
     ///
     /// # Errors
@@ -1020,6 +930,25 @@ impl AbstractSyntaxTree {
                         }
                     }
                 }
+                Type::GroupBy => {
+                    let rel_id = rel_node.children.first().ok_or_else(|| {
+                        SbroadError::UnexpectedNumberOfValues(
+                            "AST group by doesn't have any children.".into(),
+                        )
+                    })?;
+                    for top in rel_node.children.iter().skip(1) {
+                        let mut subtree =
+                            PostOrder::with_capacity(|node| self.nodes.ast_iter(node), capacity);
+                        for (_, id) in subtree.iter(*top) {
+                            let node = self.nodes.get_node(id)?;
+                            if let Type::Reference = node.rule {
+                                if let Entry::Vacant(entry) = map.entry(id) {
+                                    entry.insert(vec![*rel_id]);
+                                }
+                            }
+                        }
+                    }
+                }
                 _ => continue,
             }
         }
diff --git a/sbroad-core/src/frontend/sql/ast/tests.rs b/sbroad-core/src/frontend/sql/ast/tests.rs
index f4f3c9fa2a53d7f26ffe0d841ac14a5b5958e655..663714d953adae938c71a8317e9269cc71fa5ce4 100644
--- a/sbroad-core/src/frontend/sql/ast/tests.rs
+++ b/sbroad-core/src/frontend/sql/ast/tests.rs
@@ -55,6 +55,21 @@ fn transform_select_3() {
     assert_eq!(expected, ast);
 }
 
+#[test]
+fn transform_select_3_group_by() {
+    let query = r#"select a from t group by a"#;
+    let ast = AbstractSyntaxTree::new(query).unwrap();
+    let path = Path::new("")
+        .join("tests")
+        .join("artifactory")
+        .join("frontend")
+        .join("sql")
+        .join("transform_select_3_group_by.yaml");
+    let s = fs::read_to_string(path).unwrap();
+    let expected: AbstractSyntaxTree = AbstractSyntaxTree::from_yaml(&s).unwrap();
+    assert_eq!(expected, ast);
+}
+
 #[test]
 fn transform_select_4() {
     let query = r#"select * from t1 inner join t2 on t1.a = t2.a"#;
@@ -70,6 +85,21 @@ fn transform_select_4() {
     assert_eq!(expected, ast);
 }
 
+#[test]
+fn transform_select_4_1() {
+    let query = r#"select a, b from t1 where a > 1 group by a, b"#;
+    let ast = AbstractSyntaxTree::new(query).unwrap();
+    let path = Path::new("")
+        .join("tests")
+        .join("artifactory")
+        .join("frontend")
+        .join("sql")
+        .join("transform_select_4_1.yaml");
+    let s = fs::read_to_string(path).unwrap();
+    let expected: AbstractSyntaxTree = AbstractSyntaxTree::from_yaml(&s).unwrap();
+    assert_eq!(expected, ast);
+}
+
 #[test]
 fn transform_select_5() {
     let query = r#"select * from t1 inner join t2 on t1.a = t2.a where t1.a > 0"#;
@@ -85,6 +115,36 @@ fn transform_select_5() {
     assert_eq!(expected, ast);
 }
 
+#[test]
+fn transform_select_5_1() {
+    let query = r#"select t1.a, t2.b from t1 inner join t2 on t1.a = t2.a group by t1.a, t2.b"#;
+    let ast = AbstractSyntaxTree::new(query).unwrap();
+    let path = Path::new("")
+        .join("tests")
+        .join("artifactory")
+        .join("frontend")
+        .join("sql")
+        .join("transform_select_5_1.yaml");
+    let s = fs::read_to_string(path).unwrap();
+    let expected: AbstractSyntaxTree = AbstractSyntaxTree::from_yaml(&s).unwrap();
+    assert_eq!(expected, ast);
+}
+
+#[test]
+fn transform_select_6() {
+    let query = r#"select t1.a, t2.b from t1 inner join t2 on t1.a = t2.a where t1.a > 1 group by t1.a, t2.b"#;
+    let ast = AbstractSyntaxTree::new(query).unwrap();
+    let path = Path::new("")
+        .join("tests")
+        .join("artifactory")
+        .join("frontend")
+        .join("sql")
+        .join("transform_select_6.yaml");
+    let s = fs::read_to_string(path).unwrap();
+    let expected: AbstractSyntaxTree = AbstractSyntaxTree::from_yaml(&s).unwrap();
+    assert_eq!(expected, ast);
+}
+
 #[test]
 fn traversal() {
     let query = r#"select a from t where a = 1"#;
diff --git a/sbroad-core/src/frontend/sql/ir.rs b/sbroad-core/src/frontend/sql/ir.rs
index e126b4ec8655f0ff73709cf4c800ab7da441620f..aa792d2461e42cfe05ea180fdadaca6539bd9790 100644
--- a/sbroad-core/src/frontend/sql/ir.rs
+++ b/sbroad-core/src/frontend/sql/ir.rs
@@ -352,7 +352,7 @@ impl Plan {
         Ok(())
     }
 
-    fn clone_expr_subtree(&mut self, top_id: usize) -> Result<usize, SbroadError> {
+    pub(crate) fn clone_expr_subtree(&mut self, top_id: usize) -> Result<usize, SbroadError> {
         let mut map = HashMap::new();
         let mut subtree =
             PostOrder::with_capacity(|node| self.nodes.expr_iter(node, false), EXPR_CAPACITY);
diff --git a/sbroad-core/src/frontend/sql/ir/tests.rs b/sbroad-core/src/frontend/sql/ir/tests.rs
index 3c22ff39093ba35c301ab8c5d13272e8edcf6bc7..6f8d0bf45858ab6216243333d1f53a04f9a77baf 100644
--- a/sbroad-core/src/frontend/sql/ir/tests.rs
+++ b/sbroad-core/src/frontend/sql/ir/tests.rs
@@ -1,3 +1,6 @@
+use crate::executor::engine::mock::RouterConfigurationMock;
+use crate::frontend::sql::ast::AbstractSyntaxTree;
+use crate::frontend::Ast;
 use crate::ir::transformation::helpers::sql_to_optimized_ir;
 use pretty_assertions::assert_eq;
 
@@ -359,6 +362,170 @@ fn front_sql20() {
     assert_eq!(expected_explain, plan.as_explain().unwrap());
 }
 
+#[test]
+fn front_sql_groupby() {
+    let input = r#"SELECT "identification_number", "product_code" FROM "hash_testing" group by "identification_number", "product_code""#;
+
+    let plan = sql_to_optimized_ir(input, vec![]);
+    let expected_explain = String::from(
+        r#"projection ("identification_number" -> "identification_number", "product_code" -> "product_code")
+    group by ("identification_number" -> "identification_number", "product_code" -> "product_code")
+        motion [policy: segment([ref("identification_number"), ref("product_code")]), generation: none]
+            scan 
+                projection ("hash_testing"."identification_number" -> "identification_number", "hash_testing"."product_code" -> "product_code")
+                    group by ("hash_testing"."identification_number" -> "identification_number", "hash_testing"."product_code" -> "product_code")
+                        scan "hash_testing"
+"#,
+    );
+
+    assert_eq!(expected_explain, plan.as_explain().unwrap());
+}
+
+#[test]
+fn front_sql_groupby_less_cols_in_proj() {
+    // check case when we specify less columns than in groupby clause
+    let input = r#"SELECT "identification_number" FROM "hash_testing"
+        GROUP BY "identification_number", "product_units"
+        "#;
+
+    let plan = sql_to_optimized_ir(input, vec![]);
+
+    let expected_explain = String::from(
+        r#"projection ("identification_number" -> "identification_number")
+    group by ("identification_number" -> "identification_number", "product_units" -> "product_units")
+        motion [policy: segment([ref("identification_number"), ref("product_units")]), generation: none]
+            scan 
+                projection ("hash_testing"."identification_number" -> "identification_number", "hash_testing"."product_units" -> "product_units")
+                    group by ("hash_testing"."identification_number" -> "identification_number", "hash_testing"."product_units" -> "product_units")
+                        scan "hash_testing"
+"#,
+    );
+
+    assert_eq!(expected_explain, plan.as_explain().unwrap());
+}
+
+#[test]
+fn front_sql_groupby_union_1() {
+    let input = r#"SELECT "identification_number" FROM "hash_testing"
+        GROUP BY "identification_number"
+        UNION ALL
+        SELECT "identification_number" FROM "hash_testing""#;
+
+    let plan = sql_to_optimized_ir(input, vec![]);
+
+    let expected_explain = String::from(
+        r#"union all
+    projection ("identification_number" -> "identification_number")
+        group by ("identification_number" -> "identification_number")
+            motion [policy: segment([ref("identification_number")]), generation: none]
+                scan 
+                    projection ("hash_testing"."identification_number" -> "identification_number")
+                        group by ("hash_testing"."identification_number" -> "identification_number")
+                            scan "hash_testing"
+    projection ("hash_testing"."identification_number" -> "identification_number")
+        scan "hash_testing"
+"#,
+    );
+
+    assert_eq!(expected_explain, plan.as_explain().unwrap());
+}
+
+#[test]
+fn front_sql_groupby_union_2() {
+    let input = r#"SELECT "identification_number" FROM "hash_testing" UNION ALL
+        SELECT * FROM (SELECT "identification_number" FROM "hash_testing"
+        GROUP BY "identification_number"
+        UNION ALL
+        SELECT "identification_number" FROM "hash_testing")"#;
+
+    let plan = sql_to_optimized_ir(input, vec![]);
+
+    let expected_explain = String::from(
+        r#"union all
+    projection ("hash_testing"."identification_number" -> "identification_number")
+        scan "hash_testing"
+    projection ("identification_number" -> "identification_number")
+        scan
+            union all
+                projection ("identification_number" -> "identification_number")
+                    group by ("identification_number" -> "identification_number")
+                        motion [policy: segment([ref("identification_number")]), generation: none]
+                            scan 
+                                projection ("hash_testing"."identification_number" -> "identification_number")
+                                    group by ("hash_testing"."identification_number" -> "identification_number")
+                                        scan "hash_testing"
+                projection ("hash_testing"."identification_number" -> "identification_number")
+                    scan "hash_testing"
+"#,
+    );
+
+    assert_eq!(expected_explain, plan.as_explain().unwrap());
+}
+
+#[test]
+fn front_sql_groupby_join_1() {
+    // inner select is a kostyl because tables have the col sys_op
+    let input = r#"SELECT "product_code", "product_units" FROM (SELECT "product_units", "product_code", "identification_number" FROM "hash_testing") as t2
+        INNER JOIN (SELECT "id" from "test_space") as t
+        ON t2."identification_number" = t."id"
+        group by t2."product_code", t2."product_units"
+        "#;
+
+    let plan = sql_to_optimized_ir(input, vec![]);
+
+    let expected_explain = String::from(
+        r#"projection ("product_code" -> "product_code", "product_units" -> "product_units")
+    group by ("product_code" -> "product_code", "product_units" -> "product_units")
+        motion [policy: segment([ref("product_code"), ref("product_units")]), generation: none]
+            scan 
+                projection ("T2"."product_code" -> "product_code", "T2"."product_units" -> "product_units")
+                    group by ("T2"."product_code" -> "product_code", "T2"."product_units" -> "product_units")
+                        join on ROW("T2"."identification_number") = ROW("T"."id")
+                            scan "T2"
+                                projection ("hash_testing"."product_units" -> "product_units", "hash_testing"."product_code" -> "product_code", "hash_testing"."identification_number" -> "identification_number")
+                                    scan "hash_testing"
+                            motion [policy: full, generation: none]
+                                scan "T"
+                                    projection ("test_space"."id" -> "id")
+                                        scan "test_space"
+"#,
+    );
+    assert_eq!(expected_explain, plan.as_explain().unwrap());
+}
+
+#[test]
+fn front_sql_groupby_insert() {
+    let input = r#"INSERT INTO "t" ("a", "c") SELECT "b", "d" FROM "t" group by "b", "d""#;
+
+    let plan = sql_to_optimized_ir(input, vec![]);
+
+    let expected_explain = String::from(
+        r#"insert "t"
+    motion [policy: segment([ref("b"), value(NULL)]), generation: sharding_column]
+        projection ("b" -> "b", "d" -> "d")
+            group by ("b" -> "b", "d" -> "d")
+                motion [policy: segment([ref("b"), ref("d")]), generation: none]
+                    scan 
+                        projection ("t"."b" -> "b", "t"."d" -> "d")
+                            group by ("t"."b" -> "b", "t"."d" -> "d")
+                                scan "t"
+"#,
+    );
+
+    assert_eq!(expected_explain, plan.as_explain().unwrap());
+}
+
+#[test]
+fn front_sql_groupby_invalid() {
+    let input = r#"select "b", "a" from "t" group by "b""#;
+
+    let metadata = &RouterConfigurationMock::new();
+    let ast = AbstractSyntaxTree::new(input).unwrap();
+    let plan = ast.resolve_metadata(metadata);
+
+    assert_eq!(true, plan.is_err());
+}
+
 #[test]
 fn front_sql_nested_subqueries() {
     let input = r#"SELECT "a" FROM "t"
diff --git a/sbroad-core/src/frontend/sql/query.pest b/sbroad-core/src/frontend/sql/query.pest
index 981e6ef9061c1f581f23f96f2af16c976a346bb8..30df0dfb621dbfe2d01f51b4fdf15b5b29c192f3 100644
--- a/sbroad-core/src/frontend/sql/query.pest
+++ b/sbroad-core/src/frontend/sql/query.pest
@@ -7,7 +7,8 @@ Query = _{ Except | UnionAll | Select | Values | Insert }
     Select = {
         ^"select" ~ Projection ~ ^"from" ~ Scan ~
         (((^"inner" ~ ^"join") | ^"join") ~ InnerJoin ~
-        ^"on" ~ Condition)? ~ (^"where" ~ Selection)?
+        ^"on" ~ Condition)? ~ (^"where" ~ Selection)? ~
+        (^"group" ~ ^"by" ~ GroupBy)?
     }
         Projection = { (Asterisk | ArithmeticExprAlias | Column) ~ ("," ~ (Asterisk | ArithmeticExprAlias | Column))*? }
             Column = { Alias | Value }
@@ -24,6 +25,7 @@ Query = _{ Except | UnionAll | Select | Values | Insert }
             Table = @{ Name }
         InnerJoin = { Scan }
         Condition = { Expr }
+        GroupBy = { GroupingElement ~ ("," ~ GroupingElement)* }
     UnionAll = { (SubQuery | Select) ~ ^"union" ~ ^"all" ~ (SubQuery | Select) }
     Except = { (SubQuery | Select) ~ ((^"except" ~ ^"distinct") | ^"except") ~ (SubQuery | Select) }
     SubQuery = { "(" ~ (Except | UnionAll | Select | Values) ~ ")" }
@@ -113,6 +115,7 @@ Concat = { ConcatLeft ~ ^"||" ~ ConcatRight }
     ConcatLeft = _{ SingleQuotedString | Cast | Function | Reference }
     ConcatRight = _{ Concat | ConcatLeft }
 
+GroupingElement = _{ Concat | Cast | Function | Reference }
 
 NameLetters = _{ ('А' .. 'Я' | 'а' .. 'я' | 'A' .. 'Z' | 'a'..'z' | "-" | "_") }
 NameString = @{ !(WHITESPACE* ~ Keyword ~ WHITESPACE) ~ ((NameLetters ~ (NameLetters | ASCII_DIGIT)+) | NameLetters+) }
diff --git a/sbroad-core/src/ir.rs b/sbroad-core/src/ir.rs
index b1967797f77c93f6f44a23b3e536e15f0fac5418..2d4a9db0ac5719bc423108a171e9bc2be0088c81 100644
--- a/sbroad-core/src/ir.rs
+++ b/sbroad-core/src/ir.rs
@@ -4,6 +4,7 @@
 
 use base64ct::{Base64, Encoding};
 use serde::{Deserialize, Serialize};
+
 use std::slice::Iter;
 
 use expression::Expression;
@@ -511,6 +512,35 @@ impl Plan {
         }
     }
 
+    /// Gets list of `Row` children ids
+    ///
+    /// # Errors
+    /// - supplied id does not correspond to `Row` node
+    pub fn get_row_list(&self, row_id: usize) -> Result<&[usize], SbroadError> {
+        self.get_expression_node(row_id)?.get_row_list()
+    }
+
+    /// Gets `GroupBy` column by idx
+    ///
+    /// # Errors
+    /// - supplied index is out of range
+    /// - node is not `GroupBy`
+    pub fn get_groupby_col(&self, groupby_id: usize, col_idx: usize) -> Result<usize, SbroadError> {
+        let node = self.get_relation_node(groupby_id)?;
+        if let Relational::GroupBy { gr_cols, .. } = node {
+            let col_id = gr_cols.get(col_idx).ok_or_else(|| {
+                SbroadError::UnexpectedNumberOfValues(format!(
+                    "groupby column index out of range. Node: {node:?}"
+                ))
+            })?;
+            return Ok(*col_id);
+        }
+        Err(SbroadError::Invalid(
+            Entity::Node,
+            Some(format!("Expected GroupBy node. Got: {node:?}")),
+        ))
+    }
+
     /// Get alias string for `Reference` node
     ///
     /// # Errors
diff --git a/sbroad-core/src/ir/distribution.rs b/sbroad-core/src/ir/distribution.rs
index 0f28a343c2d935e1bce0f0d7a034dfb3e575510f..834f0fef34784530e2351ed1a62b24b633c600cb 100644
--- a/sbroad-core/src/ir/distribution.rs
+++ b/sbroad-core/src/ir/distribution.rs
@@ -81,6 +81,7 @@ pub enum Distribution {
 
 impl Distribution {
     /// Calculate a new distribution for the `Except` and `UnionAll` output tuple.
+    /// Single
     fn union_except(left: &Distribution, right: &Distribution) -> Distribution {
         match (left, right) {
             (Distribution::Any, _) | (_, Distribution::Any) => Distribution::Any,
@@ -407,16 +408,17 @@ impl Plan {
         child_pos_map: &AHashMap<ChildColumnReference, ParentColumnPosition>,
     ) -> Result<Distribution, SbroadError> {
         if let Node::Relational(relational_op) = self.get_node(child_rel_node)? {
+            let node = self.get_node(relational_op.output())?;
             if let Node::Expression(Expression::Row {
                 distribution: child_dist,
                 ..
-            }) = self.get_node(relational_op.output())?
+            }) = node
             {
                 match child_dist {
                     None => {
                         return Err(SbroadError::Invalid(
                             Entity::Distribution,
-                            Some("distribution is uninitialized".into()),
+                            Some("distribution is uninitialized".to_string()),
                         ))
                     }
                     Some(Distribution::Any) => return Ok(Distribution::Any),
@@ -458,13 +460,21 @@ impl Plan {
     /// # Errors
     /// - Node is not of a row type.
     pub fn set_const_dist(&mut self, row_id: usize) -> Result<(), SbroadError> {
+        self.set_dist(row_id, Distribution::Replicated)
+    }
+
+    /// Sets the `Distribution` of row to given one
+    ///
+    /// # Errors
+    /// - supplied node is `Row`
+    pub fn set_dist(&mut self, row_id: usize, dist: Distribution) -> Result<(), SbroadError> {
         if let Expression::Row {
             ref mut distribution,
             ..
         } = self.get_mut_expression_node(row_id)?
         {
             if distribution.is_none() {
-                *distribution = Some(Distribution::Replicated);
+                *distribution = Some(dist);
             }
             return Ok(());
         }
diff --git a/sbroad-core/src/ir/explain.rs b/sbroad-core/src/ir/explain.rs
index 6dda5e42dd87ca3cbf27266462e84a7d84868c0b..b969df9671ead0bcccb88370f481892a62ecad52 100644
--- a/sbroad-core/src/ir/explain.rs
+++ b/sbroad-core/src/ir/explain.rs
@@ -233,6 +233,44 @@ impl Display for Projection {
     }
 }
 
+#[derive(Debug, Serialize)]
+struct GroupBy {
+    /// List of colums in sql query
+    cols: Vec<Col>,
+}
+
+impl GroupBy {
+    #[allow(dead_code)]
+    fn new(plan: &Plan, output_id: usize) -> Result<Self, SbroadError> {
+        let mut result = GroupBy { cols: vec![] };
+
+        let alias_list = plan.get_expression_node(output_id)?;
+
+        for col_node_id in alias_list.get_row_list()? {
+            let col = Col::new(plan, *col_node_id)?;
+
+            result.cols.push(col);
+        }
+        Ok(result)
+    }
+}
+
+impl Display for GroupBy {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        let mut s = "group by ".to_string();
+
+        let cols = &self
+            .cols
+            .iter()
+            .map(ToString::to_string)
+            .collect::<Vec<String>>()
+            .join(", ");
+
+        write!(s, "({cols})")?;
+        write!(f, "{s}")
+    }
+}
+
 #[derive(Debug, Serialize)]
 struct Scan {
     /// Table name
@@ -587,6 +625,7 @@ impl Display for InnerJoin {
 #[allow(dead_code)]
 enum ExplainNode {
     Except,
+    GroupBy(GroupBy),
     InnerJoin(InnerJoin),
     ValueRow(Row),
     Value,
@@ -608,6 +647,7 @@ impl Display for ExplainNode {
             ExplainNode::Value => "values".to_string(),
             ExplainNode::Insert(s) => format!("insert {s}"),
             ExplainNode::Projection(e) => e.to_string(),
+            ExplainNode::GroupBy(p) => p.to_string(),
             ExplainNode::Scan(s) => s.to_string(),
             ExplainNode::Selection(s) => format!("selection {s}"),
             ExplainNode::UnionAll => "union all".to_string(),
@@ -714,6 +754,16 @@ impl FullExplain {
                     }
                     Some(ExplainNode::Except)
                 }
+                Relational::GroupBy { output, .. } => {
+                    let child = stack.pop().ok_or_else(|| {
+                        SbroadError::UnexpectedNumberOfValues(
+                            "Groupby node must have at least one child".into(),
+                        )
+                    })?;
+                    current_node.children.push(child);
+                    let p = GroupBy::new(ir, *output)?;
+                    Some(ExplainNode::GroupBy(p))
+                }
                 Relational::Projection { output, .. } => {
                     // TODO: change this logic when we'll enable sub-queries in projection
                     let child = stack.pop().ok_or_else(|| {
diff --git a/sbroad-core/src/ir/expression.rs b/sbroad-core/src/ir/expression.rs
index 976e1bf70efe3c3ef303cfa6f17786d1c40b9da5..f64006281aafc6326e38d23dcef1ee6f073c121b 100644
--- a/sbroad-core/src/ir/expression.rs
+++ b/sbroad-core/src/ir/expression.rs
@@ -671,9 +671,18 @@ impl Plan {
             })
         });
         if !all_found {
+            // If the child is a group by then the parent is projection (will be false when we add `Having` node).
+            // If this reference is inside arithmetic expression it would be better to throw an
+            // error like `select a + b group by a is wrong!`, but we can't check it here.
+            if let Relational::GroupBy { .. } = self.get_relation_node(child_node)? {
+                return Err(SbroadError::Invalid(
+                    Entity::Query,
+                    Some(format!("Invalid projection with group by clause: columns {} are not found in grouping columns!", col_names.join(", ")))
+                ));
+            }
             return Err(SbroadError::NotFound(
                 Entity::Column,
-                format!("with name {col_names:?}"),
+                format!("with name {}", col_names.join(", ")),
             ));
         }
 
diff --git a/sbroad-core/src/ir/operator.rs b/sbroad-core/src/ir/operator.rs
index fc5d1c826cbb950740e8f442c8e32a78f6728bec..9b10427d6fc865d46e894e59ebcc7eb2254f9173 100644
--- a/sbroad-core/src/ir/operator.rs
+++ b/sbroad-core/src/ir/operator.rs
@@ -3,6 +3,7 @@
 //! Contains operator nodes that transform the tuples in IR tree.
 
 use ahash::RandomState;
+
 use serde::{Deserialize, Serialize};
 use std::collections::{HashMap, HashSet};
 use std::fmt::{Display, Formatter};
@@ -251,6 +252,13 @@ pub enum Relational {
         /// Outputs tuple node index in the plan node arena.
         output: usize,
     },
+    GroupBy {
+        /// The first child is a relational operator before group by
+        children: Vec<usize>,
+        gr_cols: Vec<usize>,
+        output: usize,
+        is_final: bool,
+    },
     UnionAll {
         /// Contains exactly two elements: left and right node indexes
         /// from the plan node arena.
@@ -327,6 +335,7 @@ impl Relational {
     pub fn output(&self) -> usize {
         match self {
             Relational::Except { output, .. }
+            | Relational::GroupBy { output, .. }
             | Relational::InnerJoin { output, .. }
             | Relational::Insert { output, .. }
             | Relational::Motion { output, .. }
@@ -345,6 +354,7 @@ impl Relational {
     pub fn mut_output(&mut self) -> &mut usize {
         match self {
             Relational::Except { output, .. }
+            | Relational::GroupBy { output, .. }
             | Relational::InnerJoin { output, .. }
             | Relational::Insert { output, .. }
             | Relational::Motion { output, .. }
@@ -363,6 +373,7 @@ impl Relational {
     pub fn children(&self) -> Option<&[usize]> {
         match self {
             Relational::Except { children, .. }
+            | Relational::GroupBy { children, .. }
             | Relational::InnerJoin { children, .. }
             | Relational::Insert { children, .. }
             | Relational::Motion { children, .. }
@@ -383,6 +394,9 @@ impl Relational {
             Relational::Except {
                 ref mut children, ..
             }
+            | Relational::GroupBy {
+                ref mut children, ..
+            }
             | Relational::InnerJoin {
                 ref mut children, ..
             }
@@ -474,6 +488,10 @@ impl Relational {
                 children: ref mut old,
                 ..
             }
+            | Relational::GroupBy {
+                children: ref mut old,
+                ..
+            }
             | Relational::ValuesRow {
                 children: ref mut old,
                 ..
@@ -503,6 +521,7 @@ impl Relational {
                 alias, relation, ..
             } => Ok(alias.as_deref().or(Some(relation.as_str()))),
             Relational::Projection { .. }
+            | Relational::GroupBy { .. }
             | Relational::Selection { .. }
             | Relational::InnerJoin { .. } => {
                 let output_row = plan.get_expression_node(self.output())?;
@@ -532,7 +551,12 @@ impl Relational {
                 Ok(None)
             }
             Relational::ScanSubQuery { alias, .. } | Relational::Motion { alias, .. } => {
-                Ok(alias.as_deref())
+                if let Some(name) = alias.as_ref() {
+                    if !name.is_empty() {
+                        return Ok(alias.as_deref());
+                    }
+                }
+                Ok(None)
             }
             Relational::Except { .. }
             | Relational::UnionAll { .. }
@@ -880,6 +904,276 @@ impl Plan {
         Ok(proj_id)
     }
 
+    /// Adds `GroupBy` node to local stage of 2-stage aggregation
+    ///
+    /// # Errors:
+    /// - Node is not `GroupBy` node
+    /// - `GroupBy` node has unexpected number of children
+    /// - failed to create output or grouping cols for local `GroupBy`
+    fn add_local_groupby(&mut self, final_id: usize) -> Result<usize, SbroadError> {
+        let (final_children, final_cols, final_output) = if let Relational::GroupBy {
+            children,
+            gr_cols,
+            output,
+            ..
+        } = self.get_relation_node(final_id)?
+        {
+            (children.clone(), gr_cols.clone(), *output)
+        } else {
+            return Err(SbroadError::Invalid(
+                Entity::Node,
+                Some(format!(
+                    "add_local_groupby: expected groupby node on id: {final_id}"
+                )),
+            ));
+        };
+
+        if final_children.len() != 1 {
+            return Err(SbroadError::UnexpectedNumberOfValues(
+                "Expected groupby node to have exactly one child".into(),
+            ));
+        }
+        let mut local_cols: Vec<usize> = Vec::with_capacity(final_cols.len());
+        for col in &final_cols {
+            // When an aggregate is added, we transform expressions by adding aggregates
+            // from `HAVING` and `SELECT` clauses. Then aggregates are transformed to the MAP stage.
+            let new_col = self.clone_expr_subtree(*col)?;
+            local_cols.push(new_col);
+        }
+        let local_output = self.clone_expr_subtree(final_output)?;
+        let local_id = self.nodes.next_id();
+        for col in &local_cols {
+            self.replace_parent_in_subtree(*col, Some(final_id), Some(local_id))?;
+        }
+        let local_groupby = Relational::GroupBy {
+            children: final_children,
+            gr_cols: local_cols,
+            output: local_output,
+            is_final: false,
+        };
+        self.nodes.push(Node::Relational(local_groupby));
+        self.replace_parent_in_subtree(local_output, Some(final_id), Some(local_id))?;
+        Ok(local_id)
+    }
+
+    fn change_groupby_child_to(
+        &mut self,
+        new_child_id: usize,
+        groupby_id: usize,
+    ) -> Result<(), SbroadError> {
+        let (gr_cols_len, output) = if let Relational::GroupBy {
+            gr_cols, output, ..
+        } = self.get_relation_node(groupby_id)?
+        {
+            (gr_cols.len(), *output)
+        } else {
+            return Err(SbroadError::Invalid(
+                Entity::Node,
+                Some("change_groupby_child: expected GroupBy node".into()),
+            ));
+        };
+        let map = self
+            .get_relation_node(new_child_id)?
+            .output_alias_position_map(&self.nodes)?
+            .into_iter()
+            .map(|(k, v)| (k.to_string(), v))
+            .collect::<HashMap<String, usize>>();
+        // Update references in grouping columns
+        for i in 0..gr_cols_len {
+            let col_id = self.get_groupby_col(groupby_id, i)?;
+            let reference = self.get_expression_node(col_id)?;
+            let col_name = self.get_alias_from_reference_node(reference)?.to_string();
+            let new_pos = *map
+                .get(&col_name)
+                .ok_or_else(|| SbroadError::NotFound(Entity::Node, String::new()))?;
+            if let Expression::Reference {
+                position, parent, ..
+            } = self.get_mut_expression_node(col_id)?
+            {
+                *position = new_pos;
+                *parent = Some(new_child_id);
+            } else {
+                return Err(SbroadError::NotFound(
+                    Entity::Expression,
+                    "Reference node".into(),
+                ));
+            }
+        }
+        // Update output
+        for i in 0..self.get_row_list(output)?.len() {
+            let alias_id = self.get_row_list(output)?.get(i).ok_or_else(|| {
+                SbroadError::UnexpectedNumberOfValues("row list's size has changed!".into())
+            })?;
+            let (child, name) =
+                if let Expression::Alias { child, name } = self.get_expression_node(*alias_id)? {
+                    (*child, name.clone())
+                } else {
+                    return Err(SbroadError::Invalid(Entity::Node, None));
+                };
+            let new_pos = map
+                .get(name.as_str())
+                .ok_or_else(|| SbroadError::NotFound(Entity::Node, String::new()))?;
+            if let Expression::Reference { position, .. } = self.get_mut_expression_node(child)? {
+                *position = *new_pos;
+            } else {
+                return Err(SbroadError::NotFound(
+                    Entity::Expression,
+                    "Reference node".into(),
+                ));
+            }
+        }
+        // Update children list
+        if let Relational::GroupBy { children, .. } = self.get_mut_relation_node(groupby_id)? {
+            children[0] = new_child_id;
+        }
+        Ok(())
+    }
+
+    fn add_local_projection(&mut self, local_groupby_id: usize) -> Result<usize, SbroadError> {
+        {
+            // Check input node
+            let node = self.get_relation_node(local_groupby_id)?;
+            if !matches!(node, Relational::GroupBy { .. }) {
+                return Err(SbroadError::Invalid(Entity::Node, Some(
+                    format!("add_local_projection: expected Relational::GroupBy node on id: {local_groupby_id}, got: {node:?}"))));
+            }
+        }
+
+        let local_output = self.get_relational_output(local_groupby_id)?;
+        let proj_output = self.clone_expr_subtree(local_output)?;
+        let proj = Relational::Projection {
+            output: proj_output,
+            children: vec![local_groupby_id],
+        };
+        let proj_id = self.nodes.push(Node::Relational(proj));
+        self.replace_parent_in_subtree(proj_output, Some(local_groupby_id), Some(proj_id))?;
+        // Because the local group by is a child of a projection,
+        // position in parent's output reference is the same as index in the row list.
+        for pos in 0..self.get_row_list(proj_output)?.len() {
+            let alias_id = self.get_row_list(proj_output)?.get(pos).ok_or_else(|| {
+                SbroadError::UnexpectedNumberOfValues("row list's size has changed!".into())
+            })?;
+            let alias_node = self.get_expression_node(*alias_id)?;
+            let ref_id = if let Expression::Alias { child: ref_id, .. } = alias_node {
+                *ref_id
+            } else {
+                return Err(SbroadError::Invalid(
+                    Entity::Node,
+                    Some(format!("add_local_proj: expected projection output ({proj_output}) to consist of aliases. Got id: {alias_id}, {alias_node:?}"))
+                ));
+            };
+            let ref_node = self.get_mut_expression_node(ref_id)?;
+            if let Expression::Reference { position, .. } = ref_node {
+                *position = pos;
+            } else {
+                return Err(SbroadError::Invalid(
+                    Entity::Node,
+                    Some(format!("add_local_proj: expected projection output alias to have Reference child ({ref_id}), got: {ref_node:?}"))));
+            }
+        }
+        Ok(proj_id)
+    }
+
+    /// Adds local stage for aggregation
+    ///
+    /// # Errors
+    /// - failed to create local `GroupBy` node
+    /// - failed to create local `Projection` node
+    /// - failed to create `SQ` node
+    /// - failed to change final `GroupBy` child to `SQ`
+    pub fn add_two_stage_aggregation(&mut self, final_id: usize) -> Result<(), SbroadError> {
+        let local_id = self.add_local_groupby(final_id)?;
+        let proj_id = self.add_local_projection(local_id)?;
+        // If we generate an alias using uuid (like we do for tmp spaces) the penalty would be  redundant
+        // verbosity in the column names. We can't set an alias `None` here as well, because then the frontend
+        // would not generate parentheses for a subquery while building sql.
+        let sq_id = self.add_sub_query(proj_id, Some(""))?;
+        self.change_groupby_child_to(sq_id, final_id)?;
+        Ok(())
+    }
+
+    /// Creates output `Row` for final `GroupBy` node
+    ///
+    /// # Errors
+    /// - child node output is not `Row`
+    /// - expressions used in group by are not column references
+    /// - column references are invalid
+    pub fn add_output_groupby(
+        &mut self,
+        child_id: usize,
+        cols_ids: &[usize],
+    ) -> Result<usize, SbroadError> {
+        // For each reference we will need an alias
+        let mut row_list: Vec<usize> = Vec::with_capacity(cols_ids.len());
+        for col_id in cols_ids {
+            let child_output = self.get_row_list(self.get_relational_output(child_id)?)?;
+            let column_name = if let Expression::Reference { position, .. } =
+                self.get_expression_node(*col_id)?
+            {
+                let alias_id = *child_output.get(*position).ok_or_else(|| {
+                    SbroadError::Invalid(
+                        Entity::Node,
+                        Some("Reference have invalid position".into()),
+                    )
+                })?;
+                if let Expression::Alias { name, .. } = self.get_expression_node(alias_id)? {
+                    name.clone()
+                } else {
+                    return Err(SbroadError::Invalid(
+                        Entity::Node,
+                        Some(format!("Expected alias on id: {alias_id}")),
+                    ));
+                }
+            } else {
+                return Err(SbroadError::FailedTo(
+                    Action::Create,
+                    Some(Entity::Node),
+                    "output for groupby".into(),
+                ));
+            };
+            let ref_node = self.get_node(*col_id)?;
+            let output_ref_id = self.nodes.push(ref_node.clone());
+            row_list.push(self.nodes.add_alias(&column_name, output_ref_id)?);
+        }
+        let output = self.nodes.add_row_of_aliases(row_list, None)?;
+
+        Ok(output)
+    }
+
+    /// Adds final `GroupBy` node to `Plan`
+    ///
+    /// # Errors
+    /// - invalid children count
+    /// - failed to create output for `GroupBy`
+    pub fn add_groupby(&mut self, children: &[usize]) -> Result<usize, SbroadError> {
+        if children.len() < 2 {
+            return Err(SbroadError::Invalid(
+                Entity::Relational,
+                Some("Expected GroupBy to have at least one child".into()),
+            ));
+        }
+
+        let Some((first_child, other)) = children.split_first() else {
+            return Err(SbroadError::UnexpectedNumberOfValues("GroupBy ast has no children".into()))
+        };
+        let final_output = self.add_output_groupby(*first_child, other)?;
+        let groupby = Relational::GroupBy {
+            children: [*first_child].to_vec(),
+            gr_cols: other.to_vec(),
+            output: final_output,
+            is_final: true,
+        };
+
+        let groupby_id = self.nodes.push(Node::Relational(groupby));
+
+        self.replace_parent_in_subtree(final_output, None, Some(groupby_id))?;
+        for col in children.iter().skip(1) {
+            self.replace_parent_in_subtree(*col, None, Some(groupby_id))?;
+        }
+
+        Ok(groupby_id)
+    }
+
     /// Adds selection node
     ///
     /// # Errors
@@ -934,14 +1228,7 @@ impl Plan {
         child: usize,
         alias: Option<&str>,
     ) -> Result<usize, SbroadError> {
-        let name: Option<String> = if let Some(name) = alias {
-            if name.is_empty() {
-                return Err(SbroadError::Invalid(Entity::Name, None));
-            }
-            Some(String::from(name))
-        } else {
-            None
-        };
+        let name: Option<String> = alias.map(String::from);
 
         let output = self.add_row_for_output(child, &[], true)?;
         let sq = Relational::ScanSubQuery {
diff --git a/sbroad-core/src/ir/operator/tests.rs b/sbroad-core/src/ir/operator/tests.rs
index cd8c0638a830005f17da6b48a0a2d41521f8fedf..f7510aa1adb7b750fd8b34ef9ddedcafa484e0c3 100644
--- a/sbroad-core/src/ir/operator/tests.rs
+++ b/sbroad-core/src/ir/operator/tests.rs
@@ -106,7 +106,7 @@ fn projection() {
 
     // Invalid alias names in the output
     assert_eq!(
-        SbroadError::NotFound(Entity::Column, r#"with name ["a", "e"]"#.into()),
+        SbroadError::NotFound(Entity::Column, r#"with name a, e"#.into()),
         plan.add_proj(scan_id, &["a", "e"]).unwrap_err()
     );
 
@@ -398,12 +398,6 @@ fn sub_query() {
         SbroadError::Invalid(Entity::Node, Some("node is not Relational type".into())),
         plan.add_sub_query(a, Some("sq")).unwrap_err()
     );
-
-    // Invalid name
-    assert_eq!(
-        SbroadError::Invalid(Entity::Name, None),
-        plan.add_sub_query(scan_id, Some("")).unwrap_err()
-    );
 }
 
 #[test]
diff --git a/sbroad-core/src/ir/transformation/redistribution.rs b/sbroad-core/src/ir/transformation/redistribution.rs
index 3b25c80b7defee367bd2ab830595c3112c01e4c0..cd3f8e0f90ba0e18361b63fd7b368dee3e64e79d 100644
--- a/sbroad-core/src/ir/transformation/redistribution.rs
+++ b/sbroad-core/src/ir/transformation/redistribution.rs
@@ -11,6 +11,7 @@ use crate::ir::distribution::{Distribution, Key};
 use crate::ir::expression::Expression;
 use crate::ir::operator::{Bool, Relational};
 use crate::ir::relation::Column;
+
 use crate::ir::tree::traversal::{BreadthFirst, PostOrder, EXPR_CAPACITY, REL_CAPACITY};
 use crate::ir::value::Value;
 use crate::ir::{Node, Plan};
@@ -754,6 +755,31 @@ impl Plan {
         }
     }
 
+    #[allow(clippy::unused_self)]
+    #[must_use]
+    pub fn resolve_groupby_conflicts(
+        &self,
+        children: &[usize],
+        grouping_cols: &[usize],
+    ) -> Strategy {
+        let mut strategy: Strategy = HashMap::new();
+
+        // first columns of groupby output are grouping columns
+        let mut targets: Vec<Target> = Vec::with_capacity(grouping_cols.len());
+        for pos in 0..grouping_cols.len() {
+            targets.push(Target::Reference(pos));
+        }
+
+        strategy.insert(
+            children[0],
+            (
+                MotionPolicy::Segment(MotionKey { targets }),
+                DataGeneration::None,
+            ),
+        );
+        strategy
+    }
+
     /// Derive the motion policy for the inner child and sub-queries in the join node.
     ///
     /// # Errors
@@ -765,11 +791,13 @@ impl Plan {
         expr_id: usize,
     ) -> Result<Strategy, SbroadError> {
         // First, we need to set the motion policy for each boolean expression in the join condition.
-        let nodes = self.get_bool_nodes_with_row_children(expr_id)?;
-        for node in &nodes {
-            let bool_op = BoolOp::from_expr(self, *node)?;
-            self.set_distribution(bool_op.left)?;
-            self.set_distribution(bool_op.right)?;
+        {
+            let nodes = self.get_bool_nodes_with_row_children(expr_id)?;
+            for node in &nodes {
+                let bool_op = BoolOp::from_expr(self, *node)?;
+                self.set_distribution(bool_op.left)?;
+                self.set_distribution(bool_op.right)?;
+            }
         }
 
         // Init the strategy (motion policy map) for all the join children except the outer child.
@@ -1077,8 +1105,8 @@ impl Plan {
                 Relational::Projection { output, .. }
                 | Relational::ScanRelation { output, .. }
                 | Relational::ScanSubQuery { output, .. }
-                | Relational::UnionAll { output, .. }
                 | Relational::Values { output, .. }
+                | Relational::UnionAll { output, .. }
                 | Relational::ValuesRow { output, .. } => {
                     self.set_distribution(output)?;
                 }
@@ -1094,6 +1122,18 @@ impl Plan {
                     let strategy = self.resolve_sub_query_conflicts(*id, filter)?;
                     self.create_motion_nodes(*id, &strategy)?;
                 }
+                Relational::GroupBy {
+                    output,
+                    children,
+                    is_final,
+                    gr_cols,
+                } => {
+                    self.set_distribution(output)?;
+                    if is_final {
+                        let strategy = self.resolve_groupby_conflicts(&children, &gr_cols);
+                        self.create_motion_nodes(*id, &strategy)?;
+                    }
+                }
                 Relational::InnerJoin {
                     output, condition, ..
                 } => {
diff --git a/sbroad-core/src/ir/tree/relation.rs b/sbroad-core/src/ir/tree/relation.rs
index 543118d8179a86a966c522f944dc6d3f4f7cf0f1..81596d3d3370de15e086d46127d5463a3b7409ce 100644
--- a/sbroad-core/src/ir/tree/relation.rs
+++ b/sbroad-core/src/ir/tree/relation.rs
@@ -74,6 +74,14 @@ fn relational_next<'nodes>(
             }
             None
         }
+        Some(Node::Relational(Relational::GroupBy { children, .. })) => {
+            let step = *iter.get_child().borrow();
+            if step == 0 {
+                *iter.get_child().borrow_mut() += 1;
+                return children.get(step);
+            }
+            None
+        }
         Some(
             Node::Relational(Relational::ScanRelation { .. })
             | Node::Expression(_)
diff --git a/sbroad-core/src/ir/tree/subtree.rs b/sbroad-core/src/ir/tree/subtree.rs
index eb3013c5e0fc188b6e4d82eaa04f4f660f489182..581f0ce23784af6e28633394724abe152363cfdc 100644
--- a/sbroad-core/src/ir/tree/subtree.rs
+++ b/sbroad-core/src/ir/tree/subtree.rs
@@ -330,6 +330,28 @@ fn subtree_next<'plan>(
                     }
                     None
                 }
+                Relational::GroupBy {
+                    children,
+                    output,
+                    gr_cols,
+                    ..
+                } => {
+                    let step = *iter.get_child().borrow();
+                    if step == 0 {
+                        *iter.get_child().borrow_mut() += 1;
+                        return children.get(step);
+                    }
+                    let col_idx = step - 1;
+                    if col_idx < gr_cols.len() {
+                        *iter.get_child().borrow_mut() += 1;
+                        return gr_cols.get(col_idx);
+                    }
+                    if iter.need_output() && col_idx == gr_cols.len() {
+                        *iter.get_child().borrow_mut() += 1;
+                        return Some(output);
+                    }
+                    None
+                }
                 Relational::Motion {
                     children, output, ..
                 } => {
diff --git a/sbroad-core/src/ir/value.rs b/sbroad-core/src/ir/value.rs
index d702f37c9fd37d386f6883fcebc975172cad73d7..2389397b5bb95b9e2c859d09a984bea56aee99cb 100644
--- a/sbroad-core/src/ir/value.rs
+++ b/sbroad-core/src/ir/value.rs
@@ -14,7 +14,7 @@ use crate::executor::hash::ToHashString;
 use crate::ir::value::double::Double;
 
 #[derive(Debug, Serialize, Deserialize, Hash, PartialEq, Eq, Clone)]
-pub struct Tuple(Vec<Value>);
+pub struct Tuple(pub(crate) Vec<Value>);
 
 impl Display for Tuple {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
diff --git a/sbroad-core/src/lib.rs b/sbroad-core/src/lib.rs
index a6e3500035b15fab484b91260bdf039e2c9c091e..bdfb3600a8b07ca7935a8b63d66afc1089a79eb5 100644
--- a/sbroad-core/src/lib.rs
+++ b/sbroad-core/src/lib.rs
@@ -4,6 +4,7 @@ extern crate lazy_static;
 
 #[macro_use]
 extern crate pest_derive;
+extern crate core;
 
 pub mod backend;
 pub mod errors;
diff --git a/sbroad-core/tests/artifactory/frontend/sql/transform_select_3_group_by.yaml b/sbroad-core/tests/artifactory/frontend/sql/transform_select_3_group_by.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..41af60b2e12c75b494bd647b6acc308a925c6ecb
--- /dev/null
+++ b/sbroad-core/tests/artifactory/frontend/sql/transform_select_3_group_by.yaml
@@ -0,0 +1,56 @@
+---
+nodes:
+  arena:
+    - children:
+        - 6
+      rule: Select
+      value: ~
+    - children:
+        - 4
+        - 2
+      rule: GroupBy
+      value: ~
+    - children:
+        - 3
+      rule: Reference
+      value: ~
+    - children: []
+      rule: ColumnName
+      value: a
+    - children:
+        - 5
+      rule: Scan
+      value: ~
+    - children: []
+      rule: Table
+      value: t
+    - children:
+        - 1
+        - 7
+      rule: Projection
+      value: ~
+    - children:
+        - 11
+      rule: Column
+      value: ~
+    - children:
+        - 9
+      rule: Reference
+      value: ~
+    - children: []
+      rule: ColumnName
+      value: a
+    - children: []
+      rule: AliasName
+      value: a
+    - children:
+        - 8
+        - 10
+      rule: Alias
+      value: ~
+top: 6
+map:
+  2:
+    - 4
+  8:
+    - 1
diff --git a/sbroad-core/tests/artifactory/frontend/sql/transform_select_4_1.yaml b/sbroad-core/tests/artifactory/frontend/sql/transform_select_4_1.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..19f67cc759700fea459bb00cc537e34a94d7273e
--- /dev/null
+++ b/sbroad-core/tests/artifactory/frontend/sql/transform_select_4_1.yaml
@@ -0,0 +1,110 @@
+---
+nodes:
+  arena:
+    - children: #0
+        - 13
+      rule: Select
+      value: ~
+    - children: #1
+        - 6
+        - 4
+        - 2
+      rule: GroupBy
+      value: ~
+    - children: #2
+        - 3
+      rule: Reference
+      value: ~
+    - children: [] #3
+      rule: ColumnName
+      value: b
+    - children: #4
+        - 5
+      rule: Reference
+      value: ~
+    - children: [] #5
+      rule: ColumnName
+      value: a
+    - children: #6
+        - 11
+        - 7
+      rule: Selection
+      value: ~
+    - children: #7
+        - 9
+        - 8
+      rule: Gt
+      value: ~
+    - children: [] #8
+      rule: Unsigned
+      value: "1"
+    - children: #9
+        - 10
+      rule: Reference
+      value: ~
+    - children: [] #10
+      rule: ColumnName
+      value: a
+    - children: #11
+        - 12
+      rule: Scan
+      value: ~
+    - children: [] #12
+      rule: Table
+      value: t1
+    - children: #13
+        - 1
+        - 17
+        - 14
+      rule: Projection
+      value: ~
+    - children: #14
+        - 23
+      rule: Column
+      value: ~
+    - children: #15
+        - 16
+      rule: Reference
+      value: ~
+    - children: [] #16
+      rule: ColumnName
+      value: b
+    - children: #17
+        - 21
+      rule: Column
+      value: ~
+    - children: #18
+        - 19
+      rule: Reference
+      value: ~
+    - children: [] #19
+      rule: ColumnName
+      value: a
+    - children: [] #20
+      rule: AliasName
+      value: a
+    - children: #21
+        - 18
+        - 20
+      rule: Alias
+      value: ~
+    - children: [] #22
+      rule: AliasName
+      value: b
+    - children: #23
+        - 15
+        - 22
+      rule: Alias
+      value: ~
+top: 13
+map:
+  2:
+    - 6
+  4:
+    - 6
+  9:
+    - 11
+  15:
+    - 1
+  18:
+    - 1
diff --git a/sbroad-core/tests/artifactory/frontend/sql/transform_select_5_1.yaml b/sbroad-core/tests/artifactory/frontend/sql/transform_select_5_1.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..8e05f74b93e1cfcb7c347f4fc080a27855a15752
--- /dev/null
+++ b/sbroad-core/tests/artifactory/frontend/sql/transform_select_5_1.yaml
@@ -0,0 +1,154 @@
+---
+nodes:
+  arena:
+    - children: #0
+        - 21
+      rule: Select
+      value: ~
+    - children: #1
+        - 16
+        - 5
+        - 2
+      rule: GroupBy
+      value: ~
+    - children: #2
+        - 4
+        - 3
+      rule: Reference
+      value: ~
+    - children: [] #3
+      rule: ColumnName
+      value: b
+    - children: [] #4
+      rule: ScanName
+      value: t2
+    - children: #5
+        - 7
+        - 6
+      rule: Reference
+      value: ~
+    - children: [] #6
+      rule: ColumnName
+      value: a
+    - children: [] #7
+      rule: ScanName
+      value: t1
+    - children: #8
+        - 9
+      rule: Condition
+      value: ~
+    - children: #9
+        - 13
+        - 10
+      rule: Eq
+      value: ~
+    - children: #10
+        - 12
+        - 11
+      rule: Reference
+      value: ~
+    - children: [] #11
+      rule: ColumnName
+      value: a
+    - children: [] #12
+      rule: ScanName
+      value: t2
+    - children: #13
+        - 15
+        - 14
+      rule: Reference
+      value: ~
+    - children: [] #14
+      rule: ColumnName
+      value: a
+    - children: [] #15
+      rule: ScanName
+      value: t1
+    - children: #16
+        - 19
+        - 17
+        - 8
+      rule: InnerJoin
+      value: ~
+    - children: #17
+        - 18
+      rule: Scan
+      value: ~
+    - children: [] #18
+      rule: Table
+      value: t2
+    - children: #19
+        - 20
+      rule: Scan
+      value: ~
+    - children: [] #20
+      rule: Table
+      value: t1
+    - children: #21
+        - 1
+        - 26
+        - 22
+      rule: Projection
+      value: ~
+    - children: #22
+        - 33
+      rule: Column
+      value: ~
+    - children: #23
+        - 25
+        - 24
+      rule: Reference
+      value: ~
+    - children: [] #24
+      rule: ColumnName
+      value: b
+    - children: [] #25
+      rule: ScanName
+      value: t2
+    - children: #26
+        - 31
+      rule: Column
+      value: ~
+    - children: #27
+        - 29
+        - 28
+      rule: Reference
+      value: ~
+    - children: [] #28
+      rule: ColumnName
+      value: a
+    - children: [] #29
+      rule: ScanName
+      value: t1
+    - children: [] #30
+      rule: AliasName
+      value: a
+    - children: #31
+        - 27
+        - 30
+      rule: Alias
+      value: ~
+    - children: [] #32
+      rule: AliasName
+      value: b
+    - children: #33
+        - 23
+        - 32
+      rule: Alias
+      value: ~
+top: 21
+map:
+  23:
+    - 1
+  10:
+    - 19
+    - 17
+  2:
+    - 16
+  27:
+    - 1
+  5:
+    - 16
+  13:
+    - 19
+    - 17
diff --git a/sbroad-core/tests/artifactory/frontend/sql/transform_select_6.yaml b/sbroad-core/tests/artifactory/frontend/sql/transform_select_6.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..aed12a7fcd3aa2f62a115dd1d25b576f1b4b5159
--- /dev/null
+++ b/sbroad-core/tests/artifactory/frontend/sql/transform_select_6.yaml
@@ -0,0 +1,180 @@
+---
+nodes:
+  arena:
+    - children: #0
+        - 27
+      rule: Select
+      value: ~
+    - children: #1
+        - 8
+        - 5
+        - 2
+      rule: GroupBy
+      value: ~
+    - children: #2
+        - 4
+        - 3
+      rule: Reference
+      value: ~
+    - children: [] #3
+      rule: ColumnName
+      value: b
+    - children: [] #4
+      rule: ScanName
+      value: t2
+    - children: #5
+        - 7
+        - 6
+      rule: Reference
+      value: ~
+    - children: [] #6
+      rule: ColumnName
+      value: a
+    - children: [] #7
+      rule: ScanName
+      value: t1
+    - children: #8
+        - 22
+        - 9
+      rule: Selection
+      value: ~
+    - children: #9
+        - 11
+        - 10
+      rule: Gt
+      value: ~
+    - children: [] #10
+      rule: Unsigned
+      value: "1"
+    - children: #11
+        - 13
+        - 12
+      rule: Reference
+      value: ~
+    - children: [] #12
+      rule: ColumnName
+      value: a
+    - children: [] #13
+      rule: ScanName
+      value: t1
+    - children: #14
+        - 15
+      rule: Condition
+      value: ~
+    - children: #15
+        - 19
+        - 16
+      rule: Eq
+      value: ~
+    - children: #16
+        - 18
+        - 17
+      rule: Reference
+      value: ~
+    - children: [] #17
+      rule: ColumnName
+      value: a
+    - children: [] #18
+      rule: ScanName
+      value: t2
+    - children: #19
+        - 21
+        - 20
+      rule: Reference
+      value: ~
+    - children: [] #20
+      rule: ColumnName
+      value: a
+    - children: [] #21
+      rule: ScanName
+      value: t1
+    - children: #22
+        - 25
+        - 23
+        - 14
+      rule: InnerJoin
+      value: ~
+    - children: #23
+        - 24
+      rule: Scan
+      value: ~
+    - children: [] #24
+      rule: Table
+      value: t2
+    - children: #25
+        - 26
+      rule: Scan
+      value: ~
+    - children: [] #26
+      rule: Table
+      value: t1
+    - children: #27
+        - 1
+        - 32
+        - 28
+      rule: Projection
+      value: ~
+    - children: #28
+        - 39
+      rule: Column
+      value: ~
+    - children: #29
+        - 31
+        - 30
+      rule: Reference
+      value: ~
+    - children: [] #30
+      rule: ColumnName
+      value: b
+    - children: [] #31
+      rule: ScanName
+      value: t2
+    - children: #32
+        - 37
+      rule: Column
+      value: ~
+    - children: #33
+        - 35
+        - 34
+      rule: Reference
+      value: ~
+    - children: [] #34
+      rule: ColumnName
+      value: a
+    - children: [] #35
+      rule: ScanName
+      value: t1
+    - children: [] #36
+      rule: AliasName
+      value: a
+    - children: #37
+        - 33
+        - 36
+      rule: Alias
+      value: ~
+    - children: [] #38
+      rule: AliasName
+      value: b
+    - children: #39
+        - 29
+        - 38
+      rule: Alias
+      value: ~
+top: 27
+map:
+  33:
+    - 1
+  2:
+    - 8
+  16:
+    - 25
+    - 23
+  29:
+    - 1
+  5:
+    - 8
+  19:
+    - 25
+    - 23
+  11:
+    - 22