diff --git a/changelogs/unreleased/module-override.md b/changelogs/unreleased/module-override.md
new file mode 100644
index 0000000000000000000000000000000000000000..16429752cde1dfdb8108a55d95fcd95e3e9c28d0
--- /dev/null
+++ b/changelogs/unreleased/module-override.md
@@ -0,0 +1,3 @@
+## feature/lua
+
+* Added ability to override a built-in module by an external one (gh-7774).
diff --git a/src/lua/loaders.lua b/src/lua/loaders.lua
index 2a0c7ddcb5061426fec73c491916c4771eedef98..27490301acb1746042878e48f72690c03eb59589 100644
--- a/src/lua/loaders.lua
+++ b/src/lua/loaders.lua
@@ -175,6 +175,30 @@ local function search(name)
     return nil
 end
 
+-- Accept a loader and return a loader, which search by a prefixed
+-- module name.
+--
+-- The module receives the original (unprefixed) module name in
+-- the argument (three dots).
+local function prefix_loader(prefix, subloader)
+    return function(name)
+        local prefixed_name = prefix .. '.' .. name
+        -- On success the return value is a function, which
+        -- executes module's initialization code. require() calls
+        -- it with one argument: the module name (it can be
+        -- received in the module using three dots). Since
+        -- require() knows nothing about our prefixing it passes
+        -- the original name there.
+        --
+        -- It is expected behavior in our case. The prefixed
+        -- loaders are added to enable extra search paths: like
+        -- we would add more package.{path,cpath} entries. It
+        -- shouldn't change the string passed to the module's
+        -- initialization code.
+        return subloader(prefixed_name)
+    end
+end
+
 -- Accept an array of loaders and return a loader, whose effect is
 -- equivalent to calling the loaders in a row.
 local function chain_loaders(subloaders)
@@ -214,14 +238,26 @@ table.insert(package.loaders, 5, gen_loader_func(search_rocks_lib, load_lib))
 -- package.cpath  7
 -- croot          8
 
--- Add a loader for searching a built-in module (compiled into
--- tarantool's executable).
+-- Add two loaders:
+--
+-- - Search for override.<module_name> module. It is necessary for
+--   overriding built-in modules.
+-- - Search for a built-in module (compiled into tarantool's
+--   executable).
 --
--- The loader is mixed into the first loader to don't change
--- ordinals of the loaders 2-8. It is possible that someone
+-- Those two loaders are mixed into the first loader to don't
+-- change ordinals of the loaders 2-8. It is possible that someone
 -- has a logic based on those loader positions.
 package.loaders[1] = chain_loaders({
     package.loaders[1],
+    chain_loaders({
+        prefix_loader('override', package.loaders[2]),
+        prefix_loader('override', package.loaders[3]),
+        prefix_loader('override', package.loaders[4]),
+        prefix_loader('override', package.loaders[5]),
+        prefix_loader('override', package.loaders[6]),
+        prefix_loader('override', package.loaders[7]),
+    }),
     builtin_loader,
 })
 
diff --git a/test/app-luatest/override_test.lua b/test/app-luatest/override_test.lua
new file mode 100644
index 0000000000000000000000000000000000000000..f5b694cde5c3880abae47a5b538b50731fbbda01
--- /dev/null
+++ b/test/app-luatest/override_test.lua
@@ -0,0 +1,146 @@
+local t = require('luatest')
+local treegen = require('test.treegen')
+local justrun = require('test.justrun')
+
+local g = t.group()
+
+-- Core idea: return something that differs from the corresponding
+-- built-in module.
+--
+-- Print `...` and `arg` to ensure that they have expected
+-- value.
+local OVERRIDE_SCRIPT_TEMPLATE = [[
+print(require('json').encode({
+    ['script'] = '<script>',
+    ['...'] = {...},
+    ['arg[-1]'] = arg[-1],
+    ['arg[0]'] = arg[0],
+    ['arg[]'] = setmetatable(arg, {__serialize = 'seq'}),
+}))
+return {whoami = 'override.<module_name>'}
+]]
+
+-- Print a result of the require call.
+local MAIN_SCRIPT_TEMPLATE = [[
+print(require('json').encode({
+    ['script'] = '<script>',
+    ['<module_name>'] = require('<module_name>'),
+}))
+]]
+
+g.before_all(function(g)
+    treegen.init(g)
+    treegen.add_template(g, '^override/.*%.lua$', OVERRIDE_SCRIPT_TEMPLATE)
+    treegen.add_template(g, '^main%.lua$', MAIN_SCRIPT_TEMPLATE)
+end)
+
+g.after_all(function(g)
+    treegen.clean(g)
+end)
+
+-- Test oracle.
+--
+-- Verifies that the override module is actually returned by the
+-- require call in main.lua.
+--
+-- Also holds `...` (without 'override.') and `arg` (the same as
+-- in the main script).
+local function expected_output(module_name)
+    local module_name_as_path = table.concat(module_name:split('.'), '/')
+    local override_filename = ('override/%s.lua'):format(module_name_as_path)
+
+    local res = {
+        {
+            ['script'] = override_filename,
+            ['...'] = {module_name},
+            ['arg[-1]'] = arg[-1],
+            ['arg[0]'] = 'main.lua',
+            ['arg[]'] = {},
+        },
+        {
+            ['script'] = 'main.lua',
+            [module_name] = {
+                whoami = ('override.%s'):format(module_name)
+            },
+        },
+    }
+
+    return {
+        exit_code = 0,
+        stdout = res,
+    }
+end
+
+-- A couple of test cases with overriding built-in modules.
+--
+-- In a general case it is not a trivial task to correctly
+-- override a built-in module. However, there are modules that
+-- could be overridden with an arbitrary table and it will pass
+-- tarantool's initialization successfully.
+--
+-- We have no guarantee that any module could be overridden. It is
+-- more like 'it is generally possible'.
+--
+-- The list is collected from loaders.builtin and package.loaded
+-- keys. Many modules are excluded deliberately:
+--
+-- - json -- it is used in the test itself
+-- - bit, coroutine, debug, ffi, io, jit, jit.*, math, os,
+--   package, string, table -- LuaJIT modules
+-- - misc -- tarantool's module implemented as part of LuaJIT.
+-- - *.lib, internal.*, utils.* and so on -- tarantool internal
+--   modules
+-- - memprof.*, misc.*, sysprof.*, table.*, timezones -- unclear
+--   whether they're public
+-- - box, buffer, decimal, errno, fiber, fio, log, merger,
+--   msgpackffi, strict, tarantool, yaml -- used during
+--   tarantool's initialization in a way that doesn't allow to
+--   replace them with an arbitrary table
+local override_cases = {
+    'clock',
+    'console',
+    'crypto',
+    'csv',
+    'datetime',
+    'digest',
+    'error',
+    'fun',
+    'help',
+    'http.client',
+    'iconv',
+    'key_def',
+    'luadebug',
+    'memprof',
+    'msgpack',
+    'net.box',
+    'pickle',
+    'popen',
+    'pwd',
+    'socket',
+    'swim',
+    'sysprof',
+    'tap',
+    'title',
+    'uri',
+    'utf8',
+    'uuid',
+    'xlog',
+}
+
+-- Generate a workdir (override/foo.lua and main.lua), run
+-- tarantool, check output.
+for _, module_name in ipairs(override_cases) do
+    local module_name_as_path = table.concat(module_name:split('.'), '/')
+    local override_filename = ('override/%s.lua'):format(module_name_as_path)
+    local module_name_as_snake = table.concat(module_name:split('.'), '_')
+    local case_name = ('test_override_%s'):format(module_name_as_snake)
+
+    g[case_name] = function(g)
+        local scripts = {override_filename, 'main.lua'}
+        local replacements = {module_name = module_name}
+        local dir = treegen.prepare_directory(g, scripts, replacements)
+        local res = justrun.tarantool(dir, {}, {'main.lua'})
+        local exp = expected_output(module_name)
+        t.assert_equals(res, exp)
+    end
+end
diff --git a/test/luajit-test-init.lua b/test/luajit-test-init.lua
index a04d48b010f115eb10129962d9be3a46dd3260c9..25afcd3cae16c041a06b538006bd835c11c74967 100644
--- a/test/luajit-test-init.lua
+++ b/test/luajit-test-init.lua
@@ -100,6 +100,45 @@ rawset(_G, 'pairs', pairs_M.builtin_pairs)
 assert(pairs ~= nil)
 assert(type(pairs) == 'function')
 
+-- Remove the override loader.
+--
+-- If the override loader is enabled at least one test in the
+-- LuaJIT submodule fails: PUC-Rio-Lua-5.1-tests/attrib.lua.
+--
+-- A sketchy description of the test case of the question is the
+-- following.
+--
+-- There is a directory:
+--
+--  | + libs/
+--  | +- foo.lua
+--  | +- bar.lua
+--
+-- And the Lua code:
+--
+--  | package.path = 'libs/?.lua;libs/bar.lua'
+--  | local foo = require('foo')
+--
+-- The test case expects that `foo` contains a return value of
+-- libs/foo.lua.
+--
+-- However, when the override loader is enabled, the following
+-- occurs. The override loader attempts to find `override.foo`: it
+-- tries `libs/?.lua` first -- it means `libs/override/foo.lua`
+-- and there is no such file. Then the loader tries `libs/bar.lua`
+-- and succeeds.
+--
+-- So `foo` contains a return value of `libs/bar.lua`.
+--
+-- Unlikely there is a valid user scenario for a package.path
+-- entry without the interrogation mark. We consider this override
+-- loader behavior as valid. The loader is disabled to pass the
+-- test suite, but likely it would be more correct to discard the
+-- test case.
+local varname, subloaders = debug.getupvalue(package.loaders[1], 1)
+assert(varname == 'subloaders')
+table.remove(subloaders, 2)
+
 -- This is workaround introduced for flaky macosx tests reported by
 -- https://github.com/tarantool/tarantool/issues/7058
 collectgarbage('collect')
diff --git a/test/treegen.lua b/test/treegen.lua
index 4c7b42cdacf43a82548be517f0f9d7460b7c57ba..1988ed9313ed3f86e1a9a261be46dc6beb924418 100644
--- a/test/treegen.lua
+++ b/test/treegen.lua
@@ -31,6 +31,7 @@
 
 local fio = require('fio')
 local log = require('log')
+local fun = require('fun')
 
 local treegen = {}
 
@@ -45,17 +46,16 @@ end
 
 -- Generate a script that follows a template and write it at given
 -- script file path in given directory.
-local function write_script(g, dir, script)
+local function write_script(g, dir, script, replacements)
     local script_abspath = fio.pathjoin(dir, script)
     local flags = {'O_CREAT', 'O_WRONLY', 'O_TRUNC'}
     local mode = tonumber('644', 8)
     local fh = fio.open(script_abspath, flags, mode)
     local template = find_template(g, script)
+    local replacements = fun.chain({script = script}, replacements):tomap()
 
     log.info(('Writing a script: %s'):format(script_abspath))
-    fh:write(template:gsub('<(.-)>', {
-        script = script,
-    }))
+    fh:write(template:gsub('<(.-)>', replacements))
 
     fh:close()
 end
@@ -108,8 +108,11 @@ end
 --     + baz.lua
 --
 -- The return value is '/tmp/rfbWOJ' for this example.
-function treegen.prepare_directory(g, scripts)
+function treegen.prepare_directory(g, scripts, replacements)
+    local replacements = replacements or {}
+
     assert(type(scripts) == 'table')
+    assert(type(replacements) == 'table')
 
     local dir = fio.tempdir()
     table.insert(g.tempdirs, dir)
@@ -119,7 +122,7 @@ function treegen.prepare_directory(g, scripts)
         local scriptdir_abspath = fio.dirname(script_abspath)
         log.info(('Creating a directory: %s'):format(scriptdir_abspath))
         fio.mktree(scriptdir_abspath)
-        write_script(g, dir, script)
+        write_script(g, dir, script, replacements)
     end
 
     return dir