diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5cef03271..012a9cc52 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## [Unreleased]
 
+- Added support for the package manager Poetry. Apps must have a `pyproject.toml` + `poetry.lock` and no other package manager files (otherwise pip/Pipenv will take precedence for backwards compatibility). ([#1682](https://github.com/heroku/heroku-buildpack-python/pull/1682))
 
 ## [v263] - 2024-10-31
 
diff --git a/README.md b/README.md
index 235dffcd4..f2767f7db 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ See the [Getting Started on Heroku with Python](https://devcenter.heroku.com/art
 
 ## Application Requirements
 
-A `requirements.txt` or `Pipfile` file must be present in the root (top-level) directory of your app's source code.
+A `requirements.txt`, `Pipfile` or `poetry.lock` file must be present in the root (top-level) directory of your app's source code.
 
 ## Configuration
 
diff --git a/bin/compile b/bin/compile
index ce78b794c..9ce9cb60f 100755
--- a/bin/compile
+++ b/bin/compile
@@ -28,6 +28,7 @@ source "${BUILDPACK_DIR}/lib/package_manager.sh"
 source "${BUILDPACK_DIR}/lib/pip.sh"
 source "${BUILDPACK_DIR}/lib/pipenv.sh"
 source "${BUILDPACK_DIR}/lib/python_version.sh"
+source "${BUILDPACK_DIR}/lib/poetry.sh"
 
 compile_start_time=$(nowms)
 
@@ -166,6 +167,9 @@ case "${package_manager}" in
 		pip::install_pip_setuptools_wheel "${python_home}" "${python_major_version}"
 		pipenv::install_pipenv
 		;;
+	poetry)
+		poetry::install_poetry "${python_home}" "${CACHE_DIR}" "${EXPORT_PATH}"
+		;;
 	*)
 		utils::abort_internal_error "Unhandled package manager: ${package_manager}"
 		;;
@@ -175,8 +179,8 @@ meta_time "package_manager_install_duration" "${package_manager_install_start_ti
 # SQLite3 support.
 # Installs the sqlite3 dev headers and sqlite3 binary but not the
 # libsqlite3-0 library since that exists in the base image.
-# We skip this step on Python 3.13, as a first step towards removing this feature.
-if [[ "${python_major_version}" == +(3.8|3.9|3.10|3.11|3.12) ]]; then
+# We skip this step on Python 3.13 or when using Poetry, as a first step towards removing this feature.
+if [[ "${python_major_version}" == +(3.8|3.9|3.10|3.11|3.12) && "${package_manager}" != "poetry" ]]; then
 	install_sqlite_start_time=$(nowms)
 	source "${BUILDPACK_DIR}/bin/steps/sqlite3"
 	buildpack_sqlite3_install
@@ -192,6 +196,9 @@ case "${package_manager}" in
 	pipenv)
 		pipenv::install_dependencies
 		;;
+	poetry)
+		poetry::install_dependencies
+		;;
 	*)
 		utils::abort_internal_error "Unhandled package manager: ${package_manager}"
 		;;
diff --git a/bin/detect b/bin/detect
index 9f6303298..4b5a535eb 100755
--- a/bin/detect
+++ b/bin/detect
@@ -49,9 +49,9 @@ output::error <<EOF
 Error: Your app is configured to use the Python buildpack,
 but we couldn't find any supported Python project files.
 
-A Python app on Heroku must have either a 'requirements.txt' or
-'Pipfile' package manager file in the root directory of its
-source code.
+A Python app on Heroku must have either a 'requirements.txt',
+'Pipfile' or 'poetry.lock' package manager file in the root
+directory of its source code.
 
 Currently the root directory of your app contains:
 
diff --git a/bin/report b/bin/report
index ecc08f0fa..dcba72ef2 100755
--- a/bin/report
+++ b/bin/report
@@ -68,6 +68,7 @@ STRING_FIELDS=(
 	package_manager_multiple_found
 	pip_version
 	pipenv_version
+	poetry_version
 	python_version_major
 	python_version_reason
 	python_version
diff --git a/bin/steps/collectstatic b/bin/steps/collectstatic
index 326974036..f4b621af5 100755
--- a/bin/steps/collectstatic
+++ b/bin/steps/collectstatic
@@ -5,7 +5,7 @@
 # automatically be executed as part of the build process. If collectstatic
 # fails, your build fails.
 
-# This functionality will only activate if Django is in requirements.txt.
+# This functionality will only activate if Django is installed.
 
 # Runtime arguments:
 #   - $DISABLE_COLLECTSTATIC: disables this functionality.
diff --git a/lib/cache.sh b/lib/cache.sh
index 3e814f56e..bacebc999 100644
--- a/lib/cache.sh
+++ b/lib/cache.sh
@@ -104,6 +104,14 @@ function cache::restore() {
 					cache_invalidation_reasons+=("The Pipenv version has changed from ${cached_pipenv_version} to ${PIPENV_VERSION}")
 				fi
 				;;
+			poetry)
+				local cached_poetry_version
+				cached_poetry_version="$(meta_prev_get "poetry_version")"
+				# Poetry support was added after the metadata store, so we'll always have the version here.
+				if [[ "${cached_poetry_version}" != "${POETRY_VERSION:?}" ]]; then
+					cache_invalidation_reasons+=("The Poetry version has changed from ${cached_poetry_version:-"unknown"} to ${POETRY_VERSION}")
+				fi
+				;;
 			*)
 				utils::abort_internal_error "Unhandled package manager: ${package_manager}"
 				;;
@@ -119,6 +127,7 @@ function cache::restore() {
 
 		rm -rf \
 			"${cache_dir}/.heroku/python" \
+			"${cache_dir}/.heroku/python-poetry" \
 			"${cache_dir}/.heroku/python-stack" \
 			"${cache_dir}/.heroku/python-version" \
 			"${cache_dir}/.heroku/src" \
diff --git a/lib/package_manager.sh b/lib/package_manager.sh
index a06d212e0..48fa73d1c 100644
--- a/lib/package_manager.sh
+++ b/lib/package_manager.sh
@@ -27,6 +27,15 @@ function package_manager::determine_package_manager() {
 		package_managers_found+=(pip)
 	fi
 
+	# This must be after the requirements.txt check, so that the requirements.txt exported by
+	# `python-poetry-buildpack` takes precedence over poetry.lock, for consistency with the
+	# behaviour prior to this buildpack supporting Poetry natively. In the future the presence
+	# of multiple package manager files will be turned into an error, at which point the
+	# ordering here won't matter.
+	if [[ -f "${build_dir}/poetry.lock" ]]; then
+		package_managers_found+=(poetry)
+	fi
+
 	# TODO: Deprecate/sunset this fallback, since using setup.py declared dependencies is
 	# not a best practice, and we can only guess as to which package manager to use.
 	if ((${#package_managers_found[@]} == 0)) && [[ -f "${build_dir}/setup.py" ]]; then
@@ -47,9 +56,9 @@ function package_manager::determine_package_manager() {
 			output::error <<-EOF
 				Error: Couldn't find any supported Python package manager files.
 
-				A Python app on Heroku must have either a 'requirements.txt' or
-				'Pipfile' package manager file in the root directory of its
-				source code.
+				A Python app on Heroku must have either a 'requirements.txt',
+				'Pipfile' or 'poetry.lock' package manager file in the root
+				directory of its source code.
 
 				Currently the root directory of your app contains:
 
@@ -76,8 +85,7 @@ function package_manager::determine_package_manager() {
 			# TODO: Turn this case into an error since it results in support tickets from users
 			# who don't realise they have multiple package manager files and think their changes
 			# aren't taking effect. (We'll need to wait until after Poetry support has landed,
-			# and people have had a chance to migrate from the third-party Poetry buildpack,
-			# since using it results in both a requirements.txt and a poetry.lock.)
+			# and people have had a chance to migrate from the Poetry buildpack mentioned above.)
 			echo "${package_managers_found[0]}"
 			meta_set "package_manager_multiple_found" "$(
 				IFS=,
diff --git a/lib/poetry.sh b/lib/poetry.sh
new file mode 100644
index 000000000..bcceccd59
--- /dev/null
+++ b/lib/poetry.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+
+# This is technically redundant, since all consumers of this lib will have enabled these,
+# however, it helps Shellcheck realise the options under which these functions will run.
+set -euo pipefail
+
+POETRY_VERSION=$(utils::get_requirement_version 'poetry')
+
+function poetry::install_poetry() {
+	local python_home="${1}"
+	local cache_dir="${2}"
+	local export_file="${3}"
+
+	# We store Poetry in the build cache, since we only need it during the build.
+	local poetry_root="${cache_dir}/.heroku/python-poetry"
+
+	# We nest the venv and then symlink the `poetry` script to prevent the rest of `venv/bin/`
+	# (such as entrypoint scripts from Poetry's dependencies, or the venv's activation scripts)
+	# from being added to PATH and exposed to the app.
+	local poetry_bin_dir="${poetry_root}/bin"
+	local poetry_venv_dir="${poetry_root}/venv"
+
+	meta_set "poetry_version" "${POETRY_VERSION}"
+
+	# The earlier buildpack cache invalidation step will have already handled the case where the
+	# Poetry version has changed, so here we only need to check that a valid Poetry install exists.
+	# venvs are not relocatable, so if the cache directory were ever to change location, the cached
+	# Poetry installation would stop working. To save having to track the cache location via build
+	# metadata, we instead rely on the fact that relocating the venv would also break the absolute
+	# path `poetry` symlink created below, and that the `-e` condition not only checks that the
+	# `poetry` symlink exists, but that its target is also valid.
+	# Note: Whilst the Codon cache location remains stable from build to build, for Heroku CI the
+	# cache directory currently does not, so the cached Poetry will always be invalidated there.
+	if [[ -e "${poetry_bin_dir}/poetry" ]]; then
+		output::step "Using cached Poetry ${POETRY_VERSION}"
+	else
+		output::step "Installing Poetry ${POETRY_VERSION}"
+
+		# The Poetry directory will already exist in the relocated cache case mentioned above.
+		rm -rf "${poetry_root}"
+
+		python -m venv --without-pip "${poetry_venv_dir}"
+
+		# We use the pip wheel bundled within Python's standard library to install Poetry.
+		# Whilst Poetry does still require pip for some tasks (such as package uninstalls),
+		# it bundles its own copy for use as a fallback. As such we don't need to install pip
+		# into the Poetry venv (and in fact, Poetry wouldn't use this install anyway, since
+		# it only finds an external pip if it exists in the target venv).
+		local bundled_pip_module_path
+		bundled_pip_module_path="$(utils::bundled_pip_module_path "${python_home}")"
+
+		if ! {
+			python "${bundled_pip_module_path}" \
+				--python "${poetry_venv_dir}" \
+				install \
+				--disable-pip-version-check \
+				--no-cache-dir \
+				--no-input \
+				--quiet \
+				"poetry==${POETRY_VERSION}"
+		}; then
+			output::error <<-EOF
+				Error: Unable to install Poetry.
+
+				Try building again to see if the error resolves itself.
+
+				If that does not help, check the status of PyPI (the Python
+				package repository service), here:
+				https://status.python.org
+			EOF
+			meta_set "failure_reason" "install-poetry"
+			return 1
+		fi
+
+		mkdir -p "${poetry_bin_dir}"
+		# NB: This symlink must not use `--relative`, since we want the symlink to break if the cache
+		# (and thus venv) were ever relocated - so that it triggers a reinstall (see above).
+		ln --symbolic --no-target-directory "${poetry_venv_dir}/bin/poetry" "${poetry_bin_dir}/poetry"
+	fi
+
+	export PATH="${poetry_bin_dir}:${PATH}"
+	echo "export PATH=\"${poetry_bin_dir}:\${PATH}\"" >>"${export_file}"
+	# Force Poetry to manage the system Python site-packages instead of using venvs.
+	export POETRY_VIRTUALENVS_CREATE="false"
+	echo 'export POETRY_VIRTUALENVS_CREATE="false"' >>"${export_file}"
+}
+
+# Note: We cache site-packages since:
+# - It results in faster builds than only caching Poetry's download/wheel cache.
+# - It's safe to do so, since `poetry install --sync` fully manages the environment
+#   (including e.g. uninstalling packages when they are removed from the lockfile).
+#
+# With site-packages cached there is no need to persist Poetry's download/wheel cache in the build
+# cache, so we let Poetry write it to the home directory where it will be discarded at the end of
+# the build. We don't use `--no-cache` since the cache still offers benefits (such as avoiding
+# repeat downloads of PEP-517/518 build requirements).
+function poetry::install_dependencies() {
+	local poetry_install_command=(
+		poetry
+		install
+		--sync
+	)
+
+	# On Heroku CI, all default Poetry dependency groups are installed (i.e. all groups minus those
+	# marked as `optional = true`). Otherwise, only the 'main' Poetry dependency group is installed.
+	if [[ ! -v INSTALL_TEST ]]; then
+		poetry_install_command+=(--only main)
+	fi
+
+	# We only display the most relevant command args here, to improve the signal to noise ratio.
+	output::step "Installing dependencies using '${poetry_install_command[*]}'"
+
+	# `--compile`: Compiles Python bytecode, to improve app boot times (pip does this by default).
+	# `--no-ansi`: Whilst we'd prefer to enable colour if possible, Poetry also emits ANSI escape
+	#              codes for redrawing lines, which renders badly in persisted build logs.
+	# shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled.
+	if ! {
+		"${poetry_install_command[@]}" --compile --no-ansi --no-interaction \
+			|& tee "${WARNINGS_LOG:?}" \
+			|& grep --invert-match 'Skipping virtualenv creation' \
+			|& output::indent
+	}; then
+		show-warnings
+
+		output::error <<-EOF
+			Error: Unable to install dependencies using Poetry.
+
+			See the log output above for more information.
+		EOF
+		meta_set "failure_reason" "install-dependencies::poetry"
+		return 1
+	fi
+}
diff --git a/requirements/poetry.txt b/requirements/poetry.txt
new file mode 100644
index 000000000..8e44691cb
--- /dev/null
+++ b/requirements/poetry.txt
@@ -0,0 +1 @@
+poetry==1.8.4
diff --git a/spec/fixtures/ci_poetry/.python-version b/spec/fixtures/ci_poetry/.python-version
new file mode 100644
index 000000000..e4fba2183
--- /dev/null
+++ b/spec/fixtures/ci_poetry/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/spec/fixtures/ci_poetry/app.json b/spec/fixtures/ci_poetry/app.json
new file mode 100644
index 000000000..59617c169
--- /dev/null
+++ b/spec/fixtures/ci_poetry/app.json
@@ -0,0 +1,9 @@
+{
+  "environments": {
+    "test": {
+      "scripts": {
+        "test": "./bin/print-env-vars.sh && pytest --version"
+      }
+    }
+  }
+}
diff --git a/spec/fixtures/ci_poetry/bin/compile b/spec/fixtures/ci_poetry/bin/compile
new file mode 100755
index 000000000..fcb7055e3
--- /dev/null
+++ b/spec/fixtures/ci_poetry/bin/compile
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+# This file is run by the inline buildpack, and tests that the environment is
+# configured as expected for buildpacks that run after the Python buildpack.
+
+set -euo pipefail
+
+BUILD_DIR="${1}"
+
+cd "${BUILD_DIR}"
+
+exec bin/print-env-vars.sh
diff --git a/spec/fixtures/ci_poetry/bin/detect b/spec/fixtures/ci_poetry/bin/detect
new file mode 100755
index 000000000..68cdcc4a2
--- /dev/null
+++ b/spec/fixtures/ci_poetry/bin/detect
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+# This file is run by the inline buildpack.
+
+set -euo pipefail
+
+echo "Inline"
diff --git a/spec/fixtures/ci_poetry/bin/post_compile b/spec/fixtures/ci_poetry/bin/post_compile
new file mode 100644
index 000000000..15362f6b1
--- /dev/null
+++ b/spec/fixtures/ci_poetry/bin/post_compile
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+exec bin/print-env-vars.sh
diff --git a/spec/fixtures/ci_poetry/bin/print-env-vars.sh b/spec/fixtures/ci_poetry/bin/print-env-vars.sh
new file mode 100755
index 000000000..9e0bebe6b
--- /dev/null
+++ b/spec/fixtures/ci_poetry/bin/print-env-vars.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|BUILD_DIR|CACHE_DIR|CI_NODE_.+|DYNO|ENV_DIR|HEROKU_TEST_RUN_.+|HOME|OLDPWD|PORT|PWD|SHLVL|STACK|TERM)='
diff --git a/spec/fixtures/ci_poetry/poetry.lock b/spec/fixtures/ci_poetry/poetry.lock
new file mode 100644
index 000000000..a727a186c
--- /dev/null
+++ b/spec/fixtures/ci_poetry/poetry.lock
@@ -0,0 +1,85 @@
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+    {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
+    {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
+    {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pytest"
+version = "8.3.3"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
+    {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.5,<2"
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "2e71a7976f439ce69fc771708b83dcfc6f795072ea73a7c2de0241878cbd378a"
diff --git a/spec/fixtures/ci_poetry/pyproject.toml b/spec/fixtures/ci_poetry/pyproject.toml
new file mode 100644
index 000000000..99aa593b8
--- /dev/null
+++ b/spec/fixtures/ci_poetry/pyproject.toml
@@ -0,0 +1,9 @@
+[tool.poetry]
+package-mode = false
+
+[tool.poetry.dependencies]
+python = "^3.12"
+typing-extensions = "*"
+
+[tool.poetry.group.test.dependencies]
+pytest = "*"
diff --git a/spec/fixtures/pipenv_basic/bin/compile b/spec/fixtures/pipenv_basic/bin/compile
index db00975d6..f73ce9e2f 100755
--- a/spec/fixtures/pipenv_basic/bin/compile
+++ b/spec/fixtures/pipenv_basic/bin/compile
@@ -5,7 +5,11 @@
 
 set -euo pipefail
 
-printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)='
+BUILD_DIR="${1}"
+
+cd "${BUILD_DIR}"
+
+printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)='
 echo
 
 python -c 'import pprint, sys; pprint.pp(sys.path)'
diff --git a/spec/fixtures/poetry_basic/.python-version b/spec/fixtures/poetry_basic/.python-version
new file mode 100644
index 000000000..e4fba2183
--- /dev/null
+++ b/spec/fixtures/poetry_basic/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/spec/fixtures/poetry_basic/bin/compile b/spec/fixtures/poetry_basic/bin/compile
new file mode 100755
index 000000000..55423bd97
--- /dev/null
+++ b/spec/fixtures/poetry_basic/bin/compile
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+
+# This file is run by the inline buildpack, and tests that the environment is
+# configured as expected for buildpacks that run after the Python buildpack.
+
+set -euo pipefail
+
+BUILD_DIR="${1}"
+
+cd "${BUILD_DIR}"
+
+printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)='
+echo
+
+python -c 'import pprint, sys; pprint.pp(sys.path)'
+echo
+
+# The show command also lists dependencies that are in optional groups in pyproject.toml
+# but that aren't actually installed, for which the only option is to filter out by hand.
+poetry show | grep -v ' (!) '
+echo
+
+python -c 'import typing_extensions; print(typing_extensions)'
diff --git a/spec/fixtures/poetry_basic/bin/detect b/spec/fixtures/poetry_basic/bin/detect
new file mode 100755
index 000000000..68cdcc4a2
--- /dev/null
+++ b/spec/fixtures/poetry_basic/bin/detect
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+# This file is run by the inline buildpack.
+
+set -euo pipefail
+
+echo "Inline"
diff --git a/spec/fixtures/poetry_basic/poetry.lock b/spec/fixtures/poetry_basic/poetry.lock
new file mode 100644
index 000000000..a727a186c
--- /dev/null
+++ b/spec/fixtures/poetry_basic/poetry.lock
@@ -0,0 +1,85 @@
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+    {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
+    {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
+    {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pytest"
+version = "8.3.3"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
+    {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.5,<2"
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "2e71a7976f439ce69fc771708b83dcfc6f795072ea73a7c2de0241878cbd378a"
diff --git a/spec/fixtures/poetry_basic/pyproject.toml b/spec/fixtures/poetry_basic/pyproject.toml
new file mode 100644
index 000000000..1b21050ab
--- /dev/null
+++ b/spec/fixtures/poetry_basic/pyproject.toml
@@ -0,0 +1,10 @@
+[tool.poetry]
+package-mode = false
+
+[tool.poetry.dependencies]
+python = "^3.12"
+typing-extensions = "*"
+
+# This group shouldn't be installed due to us passing `--only main`.
+[tool.poetry.group.test.dependencies]
+pytest = "*"
diff --git a/spec/fixtures/poetry_editable/bin/compile b/spec/fixtures/poetry_editable/bin/compile
new file mode 100755
index 000000000..df17e9401
--- /dev/null
+++ b/spec/fixtures/poetry_editable/bin/compile
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+# This file is run by the inline buildpack, and tests that editable requirements are
+# usable by buildpacks that run after the Python buildpack during the build.
+
+set -euo pipefail
+
+BUILD_DIR="${1}"
+
+cd "${BUILD_DIR}"
+
+exec bin/test-entrypoints.sh
diff --git a/spec/fixtures/poetry_editable/bin/detect b/spec/fixtures/poetry_editable/bin/detect
new file mode 100755
index 000000000..68cdcc4a2
--- /dev/null
+++ b/spec/fixtures/poetry_editable/bin/detect
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+# This file is run by the inline buildpack.
+
+set -euo pipefail
+
+echo "Inline"
diff --git a/spec/fixtures/poetry_editable/bin/post_compile b/spec/fixtures/poetry_editable/bin/post_compile
new file mode 100755
index 000000000..6e77d159a
--- /dev/null
+++ b/spec/fixtures/poetry_editable/bin/post_compile
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+exec bin/test-entrypoints.sh
diff --git a/spec/fixtures/poetry_editable/bin/test-entrypoints.sh b/spec/fixtures/poetry_editable/bin/test-entrypoints.sh
new file mode 100755
index 000000000..fc941ed3f
--- /dev/null
+++ b/spec/fixtures/poetry_editable/bin/test-entrypoints.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+cd .heroku/python/lib/python*/site-packages/
+
+# List any path like strings in .pth, and finder files in site-packages.
+grep --extended-regexp --only-matching -- '/\S+' *.pth __editable___*_finder.py | sort
+echo
+
+echo -n "Running entrypoint for the pyproject.toml-based local package: "
+local_package_pyproject_toml
+
+echo -n "Running entrypoint for the setup.py-based local package: "
+local_package_setup_py
+
+echo -n "Running entrypoint for the VCS package: "
+gunicorn --version
diff --git a/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py b/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py
new file mode 100644
index 000000000..b86d791d2
--- /dev/null
+++ b/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py
@@ -0,0 +1,2 @@
+def hello():
+    print("Hello pyproject.toml!")
diff --git a/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/pyproject.toml b/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/pyproject.toml
new file mode 100644
index 000000000..f567c3777
--- /dev/null
+++ b/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/pyproject.toml
@@ -0,0 +1,6 @@
+[project]
+name = "local_package_pyproject_toml"
+version = "0.0.1"
+
+[project.scripts]
+local_package_pyproject_toml = "local_package_pyproject_toml:hello"
diff --git a/spec/fixtures/poetry_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py b/spec/fixtures/poetry_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py
new file mode 100644
index 000000000..e98d42373
--- /dev/null
+++ b/spec/fixtures/poetry_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py
@@ -0,0 +1,2 @@
+def hello():
+    print("Hello setup.py!")
diff --git a/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.cfg b/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.cfg
new file mode 100644
index 000000000..eff513964
--- /dev/null
+++ b/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.cfg
@@ -0,0 +1,10 @@
+[metadata]
+name = local_package_setup_py
+version = 0.0.1
+
+[options]
+packages = local_package_setup_py
+
+[options.entry_points]
+console_scripts =
+    local_package_setup_py = local_package_setup_py:hello
diff --git a/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.py b/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.py
new file mode 100644
index 000000000..606849326
--- /dev/null
+++ b/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.py
@@ -0,0 +1,3 @@
+from setuptools import setup
+
+setup()
diff --git a/spec/fixtures/poetry_editable/poetry.lock b/spec/fixtures/poetry_editable/poetry.lock
new file mode 100644
index 000000000..4047b0d15
--- /dev/null
+++ b/spec/fixtures/poetry_editable/poetry.lock
@@ -0,0 +1,76 @@
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
+
+[[package]]
+name = "gunicorn"
+version = "20.1.0"
+description = "WSGI HTTP Server for UNIX"
+optional = false
+python-versions = ">=3.5"
+files = []
+develop = true
+
+[package.dependencies]
+setuptools = ">=3.0"
+
+[package.extras]
+eventlet = ["eventlet (>=0.24.1)"]
+gevent = ["gevent (>=1.4.0)"]
+setproctitle = ["setproctitle"]
+tornado = ["tornado (>=0.2)"]
+
+[package.source]
+type = "git"
+url = "https://github.com/benoitc/gunicorn.git"
+reference = "20.1.0"
+resolved_reference = "61ccfd6c38d477a908e0f376757bbb884438053a"
+
+[[package]]
+name = "local_package_pyproject_toml"
+version = "0.0.1"
+description = ""
+optional = false
+python-versions = "*"
+files = []
+develop = true
+
+[package.source]
+type = "directory"
+url = "packages/local_package_pyproject_toml"
+
+[[package]]
+name = "local_package_setup_py"
+version = "0.0.1"
+description = ""
+optional = false
+python-versions = "*"
+files = []
+develop = true
+
+[package.source]
+type = "directory"
+url = "packages/local_package_setup_py"
+
+[[package]]
+name = "setuptools"
+version = "75.2.0"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"},
+    {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"},
+]
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"]
+core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
+type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "f362eb3b600ebb153055e8c4ed85f1cdeb18ffd8528710d5e1631c448a2f8adc"
diff --git a/spec/fixtures/poetry_editable/poetry_editable/__init__.py b/spec/fixtures/poetry_editable/poetry_editable/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/spec/fixtures/poetry_editable/pyproject.toml b/spec/fixtures/poetry_editable/pyproject.toml
new file mode 100644
index 000000000..7bcc6ab80
--- /dev/null
+++ b/spec/fixtures/poetry_editable/pyproject.toml
@@ -0,0 +1,15 @@
+[tool.poetry]
+name = "poetry-editable"
+version = "0.0.1"
+description = ""
+authors = []
+
+[tool.poetry.dependencies]
+python = "^3.12"
+gunicorn = { git = "https://github.com/benoitc/gunicorn.git", tag = "20.1.0", develop = true }
+local-package-pyproject-toml = { path = "packages/local_package_pyproject_toml", develop = true }
+local-package-setup-py = { path = "packages/local_package_setup_py", develop = true }
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/spec/fixtures/poetry_lockfile_out_of_sync/poetry.lock b/spec/fixtures/poetry_lockfile_out_of_sync/poetry.lock
new file mode 100644
index 000000000..8738e5fc8
--- /dev/null
+++ b/spec/fixtures/poetry_lockfile_out_of_sync/poetry.lock
@@ -0,0 +1,7 @@
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
+package = []
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "34e39677d8527182346093002688d17a5d2fc204b9eb3e094b2e6ac519028228"
diff --git a/spec/fixtures/poetry_lockfile_out_of_sync/pyproject.toml b/spec/fixtures/poetry_lockfile_out_of_sync/pyproject.toml
new file mode 100644
index 000000000..f0367e35e
--- /dev/null
+++ b/spec/fixtures/poetry_lockfile_out_of_sync/pyproject.toml
@@ -0,0 +1,8 @@
+[tool.poetry]
+package-mode = false
+
+[tool.poetry.dependencies]
+python = "^3.12"
+
+# This dependency isn't in the lockfile.
+typing-extensions = "*"
diff --git a/spec/fixtures/requirements_basic/bin/compile b/spec/fixtures/requirements_basic/bin/compile
index 68d243e7b..29cce648e 100755
--- a/spec/fixtures/requirements_basic/bin/compile
+++ b/spec/fixtures/requirements_basic/bin/compile
@@ -5,7 +5,11 @@
 
 set -euo pipefail
 
-printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)='
+BUILD_DIR="${1}"
+
+cd "${BUILD_DIR}"
+
+printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)='
 echo
 
 python -c 'import pprint, sys; pprint.pp(sys.path)'
diff --git a/spec/fixtures/requirements_txt_and_poetry_lock/.python-version b/spec/fixtures/requirements_txt_and_poetry_lock/.python-version
new file mode 100644
index 000000000..e4fba2183
--- /dev/null
+++ b/spec/fixtures/requirements_txt_and_poetry_lock/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/spec/fixtures/requirements_txt_and_poetry_lock/poetry.lock b/spec/fixtures/requirements_txt_and_poetry_lock/poetry.lock
new file mode 100644
index 000000000..7022f9054
--- /dev/null
+++ b/spec/fixtures/requirements_txt_and_poetry_lock/poetry.lock
@@ -0,0 +1,17 @@
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "2600e9ec45a8acb482a004ff092cb1453c2550aeec843430255a623cad8f7f86"
diff --git a/spec/fixtures/requirements_txt_and_poetry_lock/pyproject.toml b/spec/fixtures/requirements_txt_and_poetry_lock/pyproject.toml
new file mode 100644
index 000000000..b6077bf2c
--- /dev/null
+++ b/spec/fixtures/requirements_txt_and_poetry_lock/pyproject.toml
@@ -0,0 +1,6 @@
+[tool.poetry]
+package-mode = false
+
+[tool.poetry.dependencies]
+python = "^3.12"
+typing-extensions = "*"
diff --git a/spec/fixtures/requirements_txt_and_poetry_lock/requirements.txt b/spec/fixtures/requirements_txt_and_poetry_lock/requirements.txt
new file mode 100644
index 000000000..eec3a2223
--- /dev/null
+++ b/spec/fixtures/requirements_txt_and_poetry_lock/requirements.txt
@@ -0,0 +1,2 @@
+# This package has been picked since it has no dependencies and is small/fast to install.
+typing-extensions==4.12.2
diff --git a/spec/hatchet/ci_spec.rb b/spec/hatchet/ci_spec.rb
index 098fcdfca..06594b0a1 100644
--- a/spec/hatchet/ci_spec.rb
+++ b/spec/hatchet/ci_spec.rb
@@ -66,7 +66,6 @@
         REGEX
 
         test_run.run_again
-
         expect(test_run.output).to include(<<~OUTPUT)
           -----> Python app detected
           -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version
@@ -142,7 +141,6 @@
         REGEX
 
         test_run.run_again
-
         expect(test_run.output).to match(Regexp.new(<<~REGEX))
           -----> Python app detected
           -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version
@@ -160,4 +158,87 @@
       end
     end
   end
+
+  context 'when using Poetry' do
+    let(:app) { Hatchet::Runner.new('spec/fixtures/ci_poetry', buildpacks:) }
+
+    it 'installs both normal and test dependencies and uses cache on subsequent runs' do
+      app.run_ci do |test_run|
+        expect(test_run.output).to match(Regexp.new(<<~REGEX))
+          -----> Python app detected
+          -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version
+          -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION}
+          -----> Installing Poetry #{POETRY_VERSION}
+          -----> Installing dependencies using 'poetry install --sync'
+                 Installing dependencies from lock file
+                 
+                 Package operations: 5 installs, 0 updates, 0 removals
+                 
+                   - Installing iniconfig .+
+                   - Installing packaging .+
+                   - Installing pluggy .+
+                   - Installing pytest .+
+                   - Installing typing-extensions .+
+          -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set.
+          -----> Running bin/post_compile hook
+                 CI=true
+                 CPLUS_INCLUDE_PATH=/app/.heroku/python/include
+                 C_INCLUDE_PATH=/app/.heroku/python/include
+                 DISABLE_COLLECTSTATIC=1
+                 INSTALL_TEST=1
+                 LANG=en_US.UTF-8
+                 LC_ALL=C.UTF-8
+                 LD_LIBRARY_PATH=/app/.heroku/python/lib
+                 LIBRARY_PATH=/app/.heroku/python/lib
+                 PATH=/tmp/cache.+/.heroku/python-poetry/bin:/app/.heroku/python/bin::/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/
+                 PIP_NO_PYTHON_VERSION_WARNING=1
+                 PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config
+                 POETRY_VIRTUALENVS_CREATE=false
+                 PYTHONUNBUFFERED=1
+          -----> Inline app detected
+          LANG=en_US.UTF-8
+          LD_LIBRARY_PATH=/app/.heroku/python/lib
+          LIBRARY_PATH=/app/.heroku/python/lib
+          PATH=/app/.heroku/python/bin:/tmp/cache.+/.heroku/python-poetry/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/
+          POETRY_VIRTUALENVS_CREATE=false
+          PYTHONHASHSEED=random
+          PYTHONHOME=/app/.heroku/python
+          PYTHONPATH=/app
+          PYTHONUNBUFFERED=true
+          -----> No test-setup command provided. Skipping.
+          -----> Running test command `./bin/print-env-vars.sh && pytest --version`...
+          CI=true
+          DYNO_RAM=2560
+          FORWARDED_ALLOW_IPS=\\*
+          GUNICORN_CMD_ARGS=--access-logfile -
+          LANG=en_US.UTF-8
+          LD_LIBRARY_PATH=/app/.heroku/python/lib
+          LIBRARY_PATH=/app/.heroku/python/lib
+          PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/:/app/.sprettur/bin/
+          PYTHONHASHSEED=random
+          PYTHONHOME=/app/.heroku/python
+          PYTHONPATH=/app
+          PYTHONUNBUFFERED=true
+          WEB_CONCURRENCY=5
+          pytest 8.3.3
+          -----> test command `./bin/print-env-vars.sh && pytest --version` completed successfully
+        REGEX
+
+        test_run.run_again
+        expect(test_run.output).to include(<<~OUTPUT)
+          -----> Python app detected
+          -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version
+          -----> Restoring cache
+          -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION}
+          -----> Installing Poetry #{POETRY_VERSION}
+          -----> Installing dependencies using 'poetry install --sync'
+                 Installing dependencies from lock file
+                 
+                 No dependencies to install or update
+          -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set.
+          -----> Running bin/post_compile hook
+        OUTPUT
+      end
+    end
+  end
 end
diff --git a/spec/hatchet/detect_spec.rb b/spec/hatchet/detect_spec.rb
index dbe7f1e93..044b0010a 100644
--- a/spec/hatchet/detect_spec.rb
+++ b/spec/hatchet/detect_spec.rb
@@ -17,9 +17,9 @@
           remote:  !     Error: Your app is configured to use the Python buildpack,
           remote:  !     but we couldn't find any supported Python project files.
           remote:  !     
-          remote:  !     A Python app on Heroku must have either a 'requirements.txt' or
-          remote:  !     'Pipfile' package manager file in the root directory of its
-          remote:  !     source code.
+          remote:  !     A Python app on Heroku must have either a 'requirements.txt',
+          remote:  !     'Pipfile' or 'poetry.lock' package manager file in the root
+          remote:  !     directory of its source code.
           remote:  !     
           remote:  !     Currently the root directory of your app contains:
           remote:  !     
diff --git a/spec/hatchet/package_manager_spec.rb b/spec/hatchet/package_manager_spec.rb
index cde1f154b..e390d62e8 100644
--- a/spec/hatchet/package_manager_spec.rb
+++ b/spec/hatchet/package_manager_spec.rb
@@ -13,9 +13,9 @@
           remote: 
           remote:  !     Error: Couldn't find any supported Python package manager files.
           remote:  !     
-          remote:  !     A Python app on Heroku must have either a 'requirements.txt' or
-          remote:  !     'Pipfile' package manager file in the root directory of its
-          remote:  !     source code.
+          remote:  !     A Python app on Heroku must have either a 'requirements.txt',
+          remote:  !     'Pipfile' or 'poetry.lock' package manager file in the root
+          remote:  !     directory of its source code.
           remote:  !     
           remote:  !     Currently the root directory of your app contains:
           remote:  !     
diff --git a/spec/hatchet/poetry_spec.rb b/spec/hatchet/poetry_spec.rb
new file mode 100644
index 000000000..5aa092068
--- /dev/null
+++ b/spec/hatchet/poetry_spec.rb
@@ -0,0 +1,226 @@
+# frozen_string_literal: true
+
+require_relative '../spec_helper'
+
+RSpec.describe 'Poetry support' do
+  context 'with a poetry.lock' do
+    let(:buildpacks) { [:default, 'heroku-community/inline'] }
+    let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_basic', buildpacks:) }
+
+    it 'installs successfully using Poetry and on rebuilds uses the cache' do
+      app.deploy do |app|
+        expect(clean_output(app.output)).to include(<<~OUTPUT)
+          remote: -----> Python app detected
+          remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version
+          remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION}
+          remote: -----> Installing Poetry #{POETRY_VERSION}
+          remote: -----> Installing dependencies using 'poetry install --sync --only main'
+          remote:        Installing dependencies from lock file
+          remote:        
+          remote:        Package operations: 1 install, 0 updates, 0 removals
+          remote:        
+          remote:          - Installing typing-extensions (4.12.2)
+          remote: -----> Inline app detected
+          remote: LANG=en_US.UTF-8
+          remote: LD_LIBRARY_PATH=/app/.heroku/python/lib
+          remote: LIBRARY_PATH=/app/.heroku/python/lib
+          remote: PATH=/app/.heroku/python/bin:/tmp/codon/tmp/cache/.heroku/python-poetry/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+          remote: POETRY_VIRTUALENVS_CREATE=false
+          remote: PYTHONHASHSEED=random
+          remote: PYTHONHOME=/app/.heroku/python
+          remote: PYTHONPATH=/app
+          remote: PYTHONUNBUFFERED=true
+          remote: 
+          remote: ['',
+          remote:  '/app',
+          remote:  '/app/.heroku/python/lib/python312.zip',
+          remote:  '/app/.heroku/python/lib/python3.12',
+          remote:  '/app/.heroku/python/lib/python3.12/lib-dynload',
+          remote:  '/app/.heroku/python/lib/python3.12/site-packages']
+          remote: 
+          remote: Skipping virtualenv creation, as specified in config file.
+          remote: typing-extensions 4.12.2 Backported and Experimental Type Hints for Python ...
+          remote: 
+          remote: <module 'typing_extensions' from '/app/.heroku/python/lib/python3.12/site-packages/typing_extensions.py'>
+        OUTPUT
+        app.commit!
+        app.push!
+        expect(clean_output(app.output)).to include(<<~OUTPUT)
+          remote: -----> Python app detected
+          remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version
+          remote: -----> Restoring cache
+          remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION}
+          remote: -----> Using cached Poetry #{POETRY_VERSION}
+          remote: -----> Installing dependencies using 'poetry install --sync --only main'
+          remote:        Installing dependencies from lock file
+          remote:        
+          remote:        No dependencies to install or update
+          remote: -----> Inline app detected
+        OUTPUT
+      end
+    end
+  end
+
+  # TODO: Make this also test the Poetry version changing, the next (first) time we update Poetry,
+  # by using an older buildpack version for the initial build.
+  context 'when the requested Python version has changed since the last build' do
+    let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_basic') }
+
+    it 'clears the cache before installing' do
+      app.deploy do |app|
+        File.write('.python-version', '3.13')
+        app.commit!
+        app.push!
+        expect(clean_output(app.output)).to include(<<~OUTPUT)
+          remote: -----> Python app detected
+          remote: -----> Using Python 3.13 specified in .python-version
+          remote: -----> Discarding cache since:
+          remote:        - The Python version has changed from #{LATEST_PYTHON_3_12} to #{LATEST_PYTHON_3_13}
+          remote: -----> Installing Python #{LATEST_PYTHON_3_13}
+          remote: -----> Installing Poetry #{POETRY_VERSION}
+          remote: -----> Installing dependencies using 'poetry install --sync --only main'
+          remote:        Installing dependencies from lock file
+          remote:        
+          remote:        Package operations: 1 install, 0 updates, 0 removals
+          remote:        
+          remote:          - Installing typing-extensions (4.12.2)
+          remote: -----> Discovering process types
+        OUTPUT
+      end
+    end
+  end
+
+  context 'when the package manager has changed from pip to Poetry since the last build' do
+    let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_basic') }
+
+    it 'clears the cache before installing with Poetry' do
+      app.deploy do |app|
+        FileUtils.rm('requirements.txt')
+        FileUtils.cp(FIXTURE_DIR.join('poetry_basic/pyproject.toml'), '.')
+        FileUtils.cp(FIXTURE_DIR.join('poetry_basic/poetry.lock'), '.')
+        app.commit!
+        app.push!
+        expect(clean_output(app.output)).to include(<<~OUTPUT)
+          remote: -----> Python app detected
+          remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version
+          remote: -----> Discarding cache since:
+          remote:        - The package manager has changed from pip to poetry
+          remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION}
+          remote: -----> Installing Poetry #{POETRY_VERSION}
+          remote: -----> Installing dependencies using 'poetry install --sync --only main'
+          remote:        Installing dependencies from lock file
+          remote:        
+          remote:        Package operations: 1 install, 0 updates, 0 removals
+          remote:        
+          remote:          - Installing typing-extensions (4.12.2)
+          remote: -----> Discovering process types
+        OUTPUT
+      end
+    end
+  end
+
+  context 'when poetry.lock contains editable requirements (both VCS and local package)' do
+    let(:buildpacks) { [:default, 'heroku-community/inline'] }
+    let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_editable', buildpacks:) }
+
+    it 'rewrites .pth, .egg-link and finder paths correctly for hooks, later buildpacks, runtime and cached builds' do
+      app.deploy do |app|
+        expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX))
+          remote: -----> Running bin/post_compile hook
+          remote:        __editable___gunicorn_20_1_0_finder.py:/tmp/build_.+/.heroku/python/src/gunicorn/gunicorn'}
+          remote:        __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
+          remote:        __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'}
+          remote:        poetry_editable.pth:/tmp/build_.+
+          remote:        
+          remote:        Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
+          remote:        Running entrypoint for the setup.py-based local package: Hello setup.py!
+          remote:        Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
+          remote: -----> Inline app detected
+          remote: __editable___gunicorn_20_1_0_finder.py:/tmp/build_.+/.heroku/python/src/gunicorn/gunicorn'}
+          remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
+          remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'}
+          remote: poetry_editable.pth:/tmp/build_.+
+          remote: 
+          remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
+          remote: Running entrypoint for the setup.py-based local package: Hello setup.py!
+          remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
+        REGEX
+
+        # Test rewritten paths work at runtime.
+        expect(app.run('bin/test-entrypoints.sh')).to include(<<~OUTPUT)
+          __editable___gunicorn_20_1_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'}
+          __editable___local_package_pyproject_toml_0_0_1_finder.py:/app/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
+          __editable___local_package_setup_py_0_0_1_finder.py:/app/packages/local_package_setup_py/local_package_setup_py'}
+          poetry_editable.pth:/app
+
+          Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
+          Running entrypoint for the setup.py-based local package: Hello setup.py!
+          Running entrypoint for the VCS package: gunicorn (version 20.1.0)
+        OUTPUT
+
+        # Test that the cached .pth files work correctly.
+        app.commit!
+        app.push!
+        expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX))
+          remote: -----> Running bin/post_compile hook
+          remote:        __editable___gunicorn_20_1_0_finder.py:/tmp/build_.+/.heroku/python/src/gunicorn/gunicorn'}
+          remote:        __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
+          remote:        __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'}
+          remote:        poetry_editable.pth:/tmp/build_.+
+          remote:        
+          remote:        Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
+          remote:        Running entrypoint for the setup.py-based local package: Hello setup.py!
+          remote:        Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
+          remote: -----> Inline app detected
+          remote: __editable___gunicorn_20_1_0_finder.py:/tmp/build_.+/.heroku/python/src/gunicorn/gunicorn'}
+          remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'}
+          remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'}
+          remote: poetry_editable.pth:/tmp/build_.+
+          remote: 
+          remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml!
+          remote: Running entrypoint for the setup.py-based local package: Hello setup.py!
+          remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\)
+        REGEX
+      end
+    end
+  end
+
+  context 'when poetry.lock is out of sync with pyproject.toml' do
+    let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_lockfile_out_of_sync', allow_failure: true) }
+
+    it 'fails the build' do
+      app.deploy do |app|
+        expect(clean_output(app.output)).to include(<<~OUTPUT)
+          remote: -----> Installing dependencies using 'poetry install --sync --only main'
+          remote:        Installing dependencies from lock file
+          remote:        
+          remote:        pyproject.toml changed significantly since poetry.lock was last generated. Run `poetry lock [--no-update]` to fix the lock file.
+          remote: 
+          remote:  !     Error: Unable to install dependencies using Poetry.
+          remote:  !     
+          remote:  !     See the log output above for more information.
+          remote: 
+          remote:  !     Push rejected, failed to compile Python app.
+        OUTPUT
+      end
+    end
+  end
+
+  # This case will be turned into an error in the future, but for now is required for backwards compatibility.
+  context 'when there is both a poetry.lock and a requirements.txt' do
+    let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_txt_and_poetry_lock') }
+
+    it 'build using pip rather than Poetry' do
+      app.deploy do |app|
+        expect(clean_output(app.output)).to include(<<~OUTPUT)
+          remote: -----> Python app detected
+          remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version
+          remote: -----> Installing Python #{LATEST_PYTHON_3_12}
+          remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION}
+          remote: -----> Installing SQLite3
+          remote: -----> Installing requirements with pip
+        OUTPUT
+      end
+    end
+  end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 4fddb3764..52860db92 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -31,6 +31,7 @@ def get_requirement_version(package_name)
 SETUPTOOLS_VERSION = get_requirement_version('setuptools')
 WHEEL_VERSION = get_requirement_version('wheel')
 PIPENV_VERSION = get_requirement_version('pipenv')
+POETRY_VERSION = get_requirement_version('poetry')
 
 # Work around the return value for `default_buildpack` changing after deploy:
 # https://github.com/heroku/hatchet/issues/180