diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e447a5b6189e9b031df627c9f67a048a22d481b..ae8c3650707910a5d98245298d4cfd4af9bfa1ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,8 @@ with the `YY.MINOR.MICRO` scheme.
 
 - Clusterwide SQL supports procedure calling.
 
+- Clusterwide SQL supports procedure renaming.
+
 -->
 
 ## [24.1.1] - 2024-02-09
diff --git a/sbroad b/sbroad
index 92b200a41109d4c5137eba30c64bb8d10a090465..1dd868bef2b31189d47e7ac74e5ba29ff6912a02 160000
--- a/sbroad
+++ b/sbroad
@@ -1 +1 @@
-Subproject commit 92b200a41109d4c5137eba30c64bb8d10a090465
+Subproject commit 1dd868bef2b31189d47e7ac74e5ba29ff6912a02
diff --git a/src/access_control.rs b/src/access_control.rs
index 5040ac75ed36c47187861c61b50e84865761ac06..40b2bde0b2ec576219146a86a63b935771643344 100644
--- a/src/access_control.rs
+++ b/src/access_control.rs
@@ -242,6 +242,19 @@ fn access_check_ddl(ddl: &op::Ddl, as_user: UserId) -> tarantool::Result<()> {
                 as_user,
             )
         }
+        op::Ddl::RenameProcedure {
+            routine_id,
+            old_name,
+            owner_id,
+            ..
+        } => box_access_check_ddl_as_user(
+            old_name,
+            *routine_id,
+            *owner_id,
+            TntSchemaObjectType::Function,
+            PrivType::Alter,
+            as_user,
+        ),
     }
 }
 
diff --git a/src/cas.rs b/src/cas.rs
index c689ce27596a770a3515344f8bb903164f2aebde..87cedb06e817cf081278d29b0328c33aabf7e6d8 100644
--- a/src/cas.rs
+++ b/src/cas.rs
@@ -735,6 +735,7 @@ fn modifies_operable(op: &Op, space: SpaceId, storage: &Clusterwide) -> bool {
         Ddl::DropIndex { .. } => false,
         Ddl::CreateProcedure { .. } => false,
         Ddl::DropProcedure { .. } => false,
+        Ddl::RenameProcedure { .. } => false,
     };
     match op {
         Op::DdlPrepare { ddl, .. } => ddl_modifies(ddl),
diff --git a/src/rpc/ddl_apply.rs b/src/rpc/ddl_apply.rs
index dc4ed5ff940894f13c8240c379cf1db94abf61e9..f3fc639e6a8d0ef05d87a24231ebb364335f5b3e 100644
--- a/src/rpc/ddl_apply.rs
+++ b/src/rpc/ddl_apply.rs
@@ -1,9 +1,9 @@
 use crate::op::Ddl;
-use crate::storage::Clusterwide;
 use crate::storage::{
     ddl_create_function_on_master, ddl_create_space_on_master, ddl_drop_function_on_master,
     ddl_drop_space_on_master,
 };
+use crate::storage::{ddl_rename_function_on_master, Clusterwide};
 use crate::storage::{local_schema_version, set_local_schema_version};
 use crate::tlog;
 use crate::traft::error::Error as TraftError;
@@ -155,6 +155,17 @@ pub fn apply_schema_change(
             }
         }
 
+        Ddl::RenameProcedure {
+            routine_id,
+            ref old_name,
+            ref new_name,
+            ..
+        } => {
+            if let Err(e) = ddl_rename_function_on_master(routine_id, old_name, new_name) {
+                return Err(Error::Aborted(e.to_string()));
+            }
+        }
+
         _ => {
             todo!();
         }
diff --git a/src/schema.rs b/src/schema.rs
index 3dca7918f4a29f1b35115e42dd00668a619a55c6..30a6c61965dca49f29ee24b97d1859dbdd12bff6 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -1,4 +1,4 @@
-use sbroad::ir::ddl::Language;
+use sbroad::ir::ddl::{Language, ParamDef};
 use std::borrow::Cow;
 use std::collections::{BTreeMap, HashSet};
 use std::fmt::Display;
@@ -1031,6 +1031,8 @@ impl Encode for RoutineDef {}
 impl RoutineDef {
     /// Index (0-based) of field "operable" in _pico_routine table format.
     pub const FIELD_OPERABLE: usize = 8;
+    /// Index (0-based) of field "name" in _pico_routine table format.
+    pub const FIELD_NAME: usize = 1;
 
     /// Format of the _pico_routine global table.
     #[inline(always)]
@@ -1198,6 +1200,23 @@ impl From<Field> for tarantool::space::Field {
     }
 }
 
+#[derive(Clone, Debug)]
+pub struct RenameRoutineParams {
+    pub new_name: String,
+    pub old_name: String,
+    pub params: Option<Vec<ParamDef>>,
+}
+
+impl RenameRoutineParams {
+    pub fn func_exists(&self) -> bool {
+        func_exists(&self.old_name)
+    }
+
+    pub fn new_name_occupied(&self) -> bool {
+        func_exists(&self.new_name)
+    }
+}
+
 #[derive(Clone, Debug)]
 pub struct CreateProcParams {
     pub name: String,
@@ -1208,17 +1227,21 @@ pub struct CreateProcParams {
     pub owner: UserId,
 }
 
+fn func_exists(name: &str) -> bool {
+    let func_space = Space::from(SystemSpace::Func);
+
+    let name_idx = func_space
+        .index_cached("name")
+        .expect("_function should have an index by name");
+    let t = name_idx
+        .get(&[name])
+        .expect("reading from _function shouldn't fail");
+    t.is_some()
+}
+
 impl CreateProcParams {
     pub fn func_exists(&self) -> bool {
-        let func_space = Space::from(SystemSpace::Func);
-
-        let name_idx = func_space
-            .index_cached("name")
-            .expect("_function should have an index by name");
-        let t = name_idx
-            .get(&[&self.name])
-            .expect("reading from _function shouldn't fail");
-        t.is_some()
+        func_exists(&self.name)
     }
 
     pub fn validate(&self, storage: &Clusterwide) -> traft::Result<()> {
diff --git a/src/sql.rs b/src/sql.rs
index cd5e2c7d60dfc7c7223f469c28fe04bd60659bc1..f90b426a153dfb3b48a039cbb5bb32d4160506aa 100644
--- a/src/sql.rs
+++ b/src/sql.rs
@@ -2,8 +2,9 @@
 
 use crate::schema::{
     wait_for_ddl_commit, CreateProcParams, CreateTableParams, DistributionParam, Field,
-    PrivilegeDef, PrivilegeType, RoleDef, RoutineDef, RoutineLanguage, RoutineParamDef,
-    RoutineParams, RoutineSecurity, SchemaObjectType, ShardingFn, UserDef, ADMIN_ID,
+    PrivilegeDef, PrivilegeType, RenameRoutineParams, RoleDef, RoutineDef, RoutineLanguage,
+    RoutineParamDef, RoutineParams, RoutineSecurity, SchemaObjectType, ShardingFn, UserDef,
+    ADMIN_ID,
 };
 use crate::sql::pgproto::{
     with_portals, BoxedPortal, Describe, Descriptor, UserDescriptors, PG_PORTALS,
@@ -914,6 +915,19 @@ fn reenterable_schema_change_request(
             // Nothing to check
             Params::DropTable(name)
         }
+        IrNode::Ddl(Ddl::RenameRoutine {
+            old_name,
+            new_name,
+            params,
+            ..
+        }) => {
+            let params = RenameRoutineParams {
+                new_name,
+                old_name,
+                params,
+            };
+            Params::RenameRoutine(params)
+        }
         IrNode::Acl(Acl::DropUser { name, .. }) => {
             // Nothing to check
             Params::DropUser(name)
@@ -1039,6 +1053,42 @@ fn reenterable_schema_change_request(
                     ddl,
                 }
             }
+            Params::RenameRoutine(params) => {
+                if !params.func_exists() {
+                    // Procedure does not exist, nothing to rename
+                    return Ok(ConsumerResult { row_count: 0 });
+                }
+
+                if params.new_name_occupied() {
+                    return Err(Error::Other(
+                        format!("Name '{}' is already taken", params.new_name).into(),
+                    ));
+                }
+
+                let routine_def = node
+                    .storage
+                    .routines
+                    .by_name(&params.old_name)?
+                    .expect("if routine ddl is correct, routine must exist");
+
+                if let Some(params) = params.params.as_ref() {
+                    ensure_parameters_match(&routine_def, params)?;
+                }
+
+                let ddl = OpDdl::RenameProcedure {
+                    routine_id: routine_def.id,
+                    new_name: params.new_name.clone(),
+                    old_name: params.old_name.clone(),
+                    initiator_id: current_user,
+                    owner_id: routine_def.owner,
+                    schema_version,
+                };
+
+                Op::DdlPrepare {
+                    ddl,
+                    schema_version,
+                }
+            }
             Params::CreateTable(params) => {
                 if params.space_exists()? {
                     // Space already exists, no op needed
@@ -1311,6 +1361,7 @@ fn reenterable_schema_change_request(
         DropRole(String),
         GrantPrivilege(GrantRevokeType, String),
         RevokePrivilege(GrantRevokeType, String),
+        RenameRoutine(RenameRoutineParams),
         CreateProcedure(CreateProcParams),
         DropProcedure(String, Vec<ParamDef>),
     }
diff --git a/src/sql/pgproto.rs b/src/sql/pgproto.rs
index c20fdc76c1bbf7cb8f3bc28c4c621594982181ed..5f142990db6e516e1ed494ffd1729ad38366f156 100644
--- a/src/sql/pgproto.rs
+++ b/src/sql/pgproto.rs
@@ -255,6 +255,7 @@ pub enum CommandTag {
     Grant = 7,
     GrantRole = 8,
     Insert = 9,
+    RenameRoutine = 17,
     Revoke = 10,
     RevokeRole = 11,
     #[default]
@@ -275,6 +276,7 @@ impl From<CommandTag> for QueryType {
             CommandTag::DropTable
             | CommandTag::CreateTable
             | CommandTag::CreateProcedure
+            | CommandTag::RenameRoutine
             | CommandTag::DropProcedure => QueryType::Ddl,
             CommandTag::Delete
             | CommandTag::Insert
@@ -312,6 +314,7 @@ impl TryFrom<&Node> for CommandTag {
                 Ddl::CreateTable { .. } => Ok(CommandTag::CreateTable),
                 Ddl::CreateProc { .. } => Ok(CommandTag::CreateProcedure),
                 Ddl::DropProc { .. } => Ok(CommandTag::DropProcedure),
+                Ddl::RenameRoutine { .. } => Ok(CommandTag::RenameRoutine),
             },
             Node::Relational(rel) => match rel {
                 Relational::Delete { .. } => Ok(CommandTag::Delete),
diff --git a/src/storage.rs b/src/storage.rs
index c1afd7df6b924755b57587067ba018b51e599f25..9487e5766b7ad1205fbc460ea9f15295bc573ceb 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -2356,6 +2356,27 @@ pub fn ddl_abort_on_master(ddl: &Ddl, version: u64) -> traft::Result<()> {
             // Actual drop happens only on commit, so there's nothing to abort.
         }
 
+        Ddl::RenameProcedure {
+            routine_id,
+            ref old_name,
+            ref new_name,
+            ..
+        } => {
+            let lua = ::tarantool::lua_state();
+            lua.exec_with(
+                "local new_name = ...
+                box.schema.func.drop(new_name, {if_exists = true})
+                ",
+                (new_name,),
+            )
+            .map_err(LuaError::from)?;
+
+            lua.exec_with(LUA_CREATE_PROC_SCRIPT, (routine_id, old_name, true))
+                .map_err(LuaError::from)?;
+
+            set_local_schema_version(version)?;
+        }
+
         _ => {
             todo!();
         }
@@ -2364,29 +2385,50 @@ pub fn ddl_abort_on_master(ddl: &Ddl, version: u64) -> traft::Result<()> {
     Ok(())
 }
 
+// This script is reused between create and rename procedure,
+// when applying changes on master.
+const LUA_CREATE_PROC_SCRIPT: &str = r#"
+    local func_id, func_name, not_exists = ...
+    local def = {
+        language = 'LUA',
+        body = string.format(
+            [[function() error("function %s is used internally by picodata") end]],
+            func_name
+        ),
+        id = func_id,
+        if_not_exists = not_exists,
+    }
+    box.schema.func.create(func_name, def)"#;
+
 /// Create tarantool function which throws an error if it's called.
 /// Tarantool function is created with `if_not_exists = true`, so it's
 /// safe to call this rust function multiple times.
 pub fn ddl_create_function_on_master(func_id: u32, func_name: &str) -> traft::Result<()> {
     debug_assert!(unsafe { tarantool::ffi::tarantool::box_txn() });
     let lua = ::tarantool::lua_state();
+    lua.exec_with(LUA_CREATE_PROC_SCRIPT, (func_id, func_name, true))
+        .map_err(LuaError::from)?;
+    Ok(())
+}
+
+pub fn ddl_rename_function_on_master(id: u32, old_name: &str, new_name: &str) -> traft::Result<()> {
+    debug_assert!(unsafe { tarantool::ffi::tarantool::box_txn() });
+
+    // We can't do update/replace on _func space, so in order to rename
+    // our dummy lua function we need to drop it and create it with a
+    // new name.
+    let lua = ::tarantool::lua_state();
     lua.exec_with(
-        r#"
-        local func_id, func_name = ...
-        local def = {
-            language = 'LUA',
-            body = string.format(
-                [[function() error("function %s is used internally by picodata") end]],
-                func_name
-            ),
-            id = func_id,
-            if_not_exists = true,
-        }
-        box.schema.func.create(func_name, def)
-    "#,
-        (func_id, func_name),
+        "local func_name = ...
+        box.schema.func.drop(func_name, {if_exists = true})
+        ",
+        (old_name,),
     )
     .map_err(LuaError::from)?;
+
+    lua.exec_with(LUA_CREATE_PROC_SCRIPT, (id, new_name, false))
+        .map_err(LuaError::from)?;
+
     Ok(())
 }
 
@@ -3127,6 +3169,14 @@ impl Routines {
         ]
     }
 
+    #[inline(always)]
+    pub fn rename(&self, id: u32, new_name: &str) -> tarantool::Result<Option<RoutineDef>> {
+        let mut ops = UpdateOps::with_capacity(1);
+        ops.assign(RoutineDef::FIELD_NAME, new_name)?;
+        let tuple = self.space.update(&[id], ops)?;
+        tuple.as_ref().map(Tuple::decode).transpose()
+    }
+
     #[inline(always)]
     pub fn by_name(&self, routine_name: &str) -> tarantool::Result<Option<RoutineDef>> {
         let tuple = self.index_name.get(&[routine_name])?;
diff --git a/src/traft/node.rs b/src/traft/node.rs
index 37b2fba3c8e435f43dc075a2989677cbfc75824f..b70587d6fcb8a796fa96b9d1a913a07d2b3b23cd 100644
--- a/src/traft/node.rs
+++ b/src/traft/node.rs
@@ -1027,6 +1027,26 @@ impl NodeImpl {
                             initiator: initiator_def.name,
                         );
                     }
+                    Ddl::RenameProcedure {
+                        routine_id,
+                        old_name,
+                        new_name,
+                        initiator_id,
+                        ..
+                    } => {
+                        self.storage
+                            .routines
+                            .update_operable(routine_id, true)
+                            .expect("storage shouldn't fail");
+
+                        let initiator_def = user_by_id(initiator_id).expect("user must exist");
+                        crate::audit!(
+                            message: "renamed routine {routine_id} from {old_name} to {new_name}",
+                            title: "rename routine",
+                            severity: Medium,
+                            initiator: initiator_def.name,
+                        );
+                    }
                     _ => {
                         todo!()
                     }
@@ -1093,6 +1113,21 @@ impl NodeImpl {
                             .expect("storage shouldn't fail");
                     }
 
+                    Ddl::RenameProcedure {
+                        routine_id,
+                        old_name,
+                        ..
+                    } => {
+                        self.storage
+                            .routines
+                            .rename(routine_id, old_name.as_str())
+                            .expect("storage shouldn't fail");
+                        self.storage
+                            .routines
+                            .update_operable(routine_id, true)
+                            .expect("storage shouldn't fail");
+                    }
+
                     _ => {
                         todo!()
                     }
@@ -1412,6 +1447,21 @@ impl NodeImpl {
                     .update_operable(id, false)
                     .expect("storage shouldn't fail");
             }
+            Ddl::RenameProcedure {
+                routine_id,
+                new_name,
+                ..
+            } => {
+                self.storage
+                    .routines
+                    .rename(routine_id, new_name.as_str())?
+                    .expect("routine existence must have been checked before commit");
+
+                self.storage
+                    .routines
+                    .update_operable(routine_id, false)
+                    .expect("storage shouldn't fail");
+            }
         }
 
         self.storage
diff --git a/src/traft/op.rs b/src/traft/op.rs
index e7729814b150a73cd606deb56a37d3087ad65088..e1fe9ebb38b6848e3cd5e8d2a4c1044fcce6a182 100644
--- a/src/traft/op.rs
+++ b/src/traft/op.rs
@@ -120,6 +120,21 @@ impl std::fmt::Display for Op {
             } => {
                 write!(f, "DdlPrepare({schema_version}, DropProcedure({id}))")
             }
+            Self::DdlPrepare {
+                schema_version,
+                ddl:
+                    Ddl::RenameProcedure {
+                        routine_id,
+                        old_name,
+                        new_name,
+                        ..
+                    },
+            } => {
+                write!(
+                    f,
+                    "DdlPrepare({schema_version}, CreateProcedure({routine_id}, {old_name} -> {new_name}))"
+                )
+            }
             Self::DdlCommit => write!(f, "DdlCommit"),
             Self::DdlAbort => write!(f, "DdlAbort"),
             Self::Acl(Acl::CreateUser { user_def }) => {
@@ -535,6 +550,14 @@ pub enum Ddl {
         id: RoutineId,
         initiator: UserId,
     },
+    RenameProcedure {
+        routine_id: u32,
+        old_name: String,
+        new_name: String,
+        initiator_id: UserId,
+        owner_id: UserId,
+        schema_version: u64,
+    },
 }
 
 /// Builder for [`Op::DdlPrepare`] operations.
diff --git a/test/int/test_sql.py b/test/int/test_sql.py
index 7ce007485115e19930ad999c166ccec7c8349d53..378abc3923d0c6f83cd179821485ecaf2416e51c 100644
--- a/test/int/test_sql.py
+++ b/test/int/test_sql.py
@@ -2297,6 +2297,7 @@ def test_create_drop_procedure(cluster: Cluster):
             create procedure proc1(int, text)
             language SQL
             as $$insert into t values(?, ?)$$
+            option(timeout=3)
             """
         )
     with pytest.raises(
@@ -2568,3 +2569,134 @@ def test_call_procedure(cluster: Cluster):
         ReturnError, match="AccessDenied: Execute access to function 'proc1' is denied"
     ):
         i1.sql(""" call "proc1"(3, 3) """, user=username, password=alice_pwd)
+
+
+def test_rename_procedure(cluster: Cluster):
+    i1, i2 = cluster.deploy(instance_count=2)
+
+    data = i1.sql(
+        """
+        create table t (a int not null, b int, primary key (a))
+        using memtx
+        distributed by (b)
+        """
+    )
+    assert data["row_count"] == 1
+
+    data = i2.sql(
+        """
+        create procedure foo(int)
+        language SQL
+        as $$insert into t values(?, ?)$$
+        """
+    )
+    assert data["row_count"] == 1
+
+    data = i1.sql(
+        """
+        alter procedure foo
+        rename to bar
+        option ( timeout = 4 )
+        """
+    )
+    assert data["row_count"] == 1
+
+    data = i2.sql(
+        """
+        select "name" from "_pico_routine"
+        where "name" = 'BAR' or "name" = 'FOO'
+        """
+    )
+    assert data["rows"] == [["BAR"]]
+
+    # procedure foo doesn't exist
+    data = i1.sql(
+        """
+        alter procedure foo
+        rename to bar
+        """
+    )
+    assert data["row_count"] == 0
+
+    # rename back, use syntax with parameters
+    data = i1.sql(
+        """
+        alter procedure "BAR"(int)
+        rename to "FOO"
+        """
+    )
+    assert data["row_count"] == 1
+
+    data = i2.sql(
+        """
+        select "name" from "_pico_routine"
+        where "name" = 'BAR' or "name" = 'FOO'
+        """
+    )
+    assert data["rows"] == [["FOO"]]
+
+    data = i2.sql(
+        """
+        create procedure bar()
+        language SQL
+        as $$insert into t values(200, 200)$$
+        """
+    )
+    assert data["row_count"] == 1
+
+    with pytest.raises(ReturnError, match="Name 'FOO' is already taken"):
+        data = i1.sql(
+            """
+            alter procedure bar()
+            rename to foo
+            """
+        )
+
+    with pytest.raises(
+        ReturnError, match="routine exists but with a different signature: BAR()"
+    ):
+        data = i1.sql(
+            """
+            alter procedure bar(int)
+            rename to buzz
+            """
+        )
+
+    username = "alice"
+    alice_pwd = "Passw0rd"
+    acl = i1.sql(
+        f"""
+        create user "{username}" with password '{alice_pwd}'
+        using chap-sha1 option (timeout = 3)
+    """
+    )
+    assert acl["row_count"] == 1
+    with pytest.raises(
+        ReturnError, match="Alter access to function 'BAR' is denied for user 'alice'"
+    ):
+        i1.sql(
+            """alter procedure bar rename to buzz""", user=username, password=alice_pwd
+        )
+
+    i2.eval(
+        """
+        box.schema.func.create(
+            'foobar',
+            {
+                body = [[function(question) return 42 end]],
+                id = box.internal.generate_func_id(true)
+            }
+        )
+        """
+    )
+    with pytest.raises(ReturnError, match="ddl operation was aborted"):
+        i1.sql(
+            """
+            alter procEdurE foo rENaME tO "foobar"
+            """
+        )
+
+    data = i1.sql(""" select * from "_pico_routine" where "name" = 'foobar' """)
+    assert data["rows"] == []
+    data = i2.sql(""" select * from "_pico_routine" where "name" = 'foobar' """)
+    assert data["rows"] == []