diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E122.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E122.py
new file mode 100644
index 0000000000000..eb69d9f4ba1e5
--- /dev/null
+++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E122.py
@@ -0,0 +1,143 @@
+#: E122
+print("E122", (
+"str"))
+
+# OK
+print("E122", (
+    "str"))
+
+#: E122:6:5 E122:7:5 E122:8:1
+print(dedent(
+    '''
+        mkdir -p ./{build}/
+        mv ./build/ ./{build}/%(revision)s/
+    '''.format(
+    build='build',
+    # more stuff
+)
+))
+
+# OK
+print(dedent(
+    '''
+        mkdir -p ./{build}/
+        mv ./build/ ./{build}/%(revision)s/
+    '''.format(
+        build='build',
+        # more stuff
+    )
+))
+
+#: E122
+if True:
+    result = some_function_that_takes_arguments(
+        'a', 'b', 'c',
+        'd', 'e', 'f',
+)
+
+# OK
+if True:
+    result = some_function_that_takes_arguments(
+        'a', 'b', 'c',
+        'd', 'e', 'f',
+    )
+
+#: E122
+if some_very_very_very_long_variable_name or var \
+or another_very_long_variable_name:
+    raise Exception()
+
+# OK
+if some_very_very_very_long_variable_name or var \
+    or another_very_long_variable_name:
+    raise Exception()
+
+#: E122
+if some_very_very_very_long_variable_name or var[0] \
+or another_very_long_variable_name:
+    raise Exception()
+
+# OK
+if some_very_very_very_long_variable_name or var[0] \
+    or another_very_long_variable_name:
+    raise Exception()
+
+#: E122
+if True:
+    if some_very_very_very_long_variable_name or var \
+    or another_very_long_variable_name:
+        raise Exception()
+
+# OK
+if True:
+    if some_very_very_very_long_variable_name or var \
+        or another_very_long_variable_name:
+        raise Exception()
+
+#: E122
+if True:
+    if some_very_very_very_long_variable_name or var[0] \
+    or another_very_long_variable_name:
+        raise Exception()
+
+#: OK
+if True:
+    if some_very_very_very_long_variable_name or var[0] \
+        or another_very_long_variable_name:
+        raise Exception()
+
+#: E122
+dictionary = {
+    "is": {
+    "nested": yes(),
+    },
+}
+
+# OK
+dictionary = {
+    "is": {
+        "nested": yes(),
+    },
+}
+
+#: E122
+setup('',
+    scripts=[''],
+    classifiers=[
+    'Development Status :: 4 - Beta',
+        'Environment :: Console',
+        'Intended Audience :: Developers',
+    ])
+
+# OK
+setup('',
+    scripts=[''],
+    classifiers=[
+        'Development Status :: 4 - Beta',
+        'Environment :: Console',
+        'Intended Audience :: Developers',
+    ])
+
+#: E122:2:1
+if True:\
+print(True)
+
+# OK
+if True:\
+    print(True)
+
+# E122
+def f():
+    x = ((
+        (
+        )
+    )
+    + 2)
+
+# OK
+def f():
+    x = ((
+        (
+        )
+    )
+        + 2)
diff --git a/crates/ruff_linter/src/checkers/logical_lines.rs b/crates/ruff_linter/src/checkers/logical_lines.rs
index 4044e6c18a67b..a7b152a9511be 100644
--- a/crates/ruff_linter/src/checkers/logical_lines.rs
+++ b/crates/ruff_linter/src/checkers/logical_lines.rs
@@ -1,4 +1,5 @@
 use crate::line_width::IndentWidth;
+use crate::registry::Rule;
 use ruff_diagnostics::Diagnostic;
 use ruff_python_codegen::Stylist;
 use ruff_python_index::Indexer;
@@ -9,10 +10,11 @@ use ruff_text_size::{Ranged, TextRange};
 
 use crate::registry::AsRule;
 use crate::rules::pycodestyle::rules::logical_lines::{
-    extraneous_whitespace, indentation, missing_whitespace, missing_whitespace_after_keyword,
-    missing_whitespace_around_operator, redundant_backslash, space_after_comma,
-    space_around_operator, whitespace_around_keywords, whitespace_around_named_parameter_equals,
-    whitespace_before_comment, whitespace_before_parameters, LogicalLines, TokenFlags,
+    extraneous_whitespace, indentation, missing_or_outdented_indentation, missing_whitespace,
+    missing_whitespace_after_keyword, missing_whitespace_around_operator, redundant_backslash,
+    space_after_comma, space_around_operator, whitespace_around_keywords,
+    whitespace_around_named_parameter_equals, whitespace_before_comment,
+    whitespace_before_parameters, LogicalLines, TokenFlags,
 };
 use crate::settings::LinterSettings;
 
@@ -106,6 +108,16 @@ pub(crate) fn check_logical_lines(
             }
         }
 
+        if settings.rules.enabled(Rule::MissingOrOutdentedIndentation) {
+            missing_or_outdented_indentation(
+                &line,
+                indent_level,
+                settings.tab_size,
+                locator,
+                &mut context,
+            );
+        }
+
         if !line.is_comment_only() {
             prev_line = Some(line);
             prev_indent_level = Some(indent_level);
diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs
index 05906db3c906b..d03e542139a6f 100644
--- a/crates/ruff_linter/src/codes.rs
+++ b/crates/ruff_linter/src/codes.rs
@@ -85,6 +85,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
         (Pycodestyle, "E116") => (RuleGroup::Nursery, rules::pycodestyle::rules::logical_lines::UnexpectedIndentationComment),
         #[allow(deprecated)]
         (Pycodestyle, "E117") => (RuleGroup::Nursery, rules::pycodestyle::rules::logical_lines::OverIndented),
+        (Pycodestyle, "E122") => (RuleGroup::Preview, rules::pycodestyle::rules::logical_lines::MissingOrOutdentedIndentation),
         #[allow(deprecated)]
         (Pycodestyle, "E201") => (RuleGroup::Nursery, rules::pycodestyle::rules::logical_lines::WhitespaceAfterOpenBracket),
         #[allow(deprecated)]
diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs
index 77b5d97df557f..4dd7e1a844d1d 100644
--- a/crates/ruff_linter/src/registry.rs
+++ b/crates/ruff_linter/src/registry.rs
@@ -305,6 +305,7 @@ impl Rule {
             Rule::ImplicitNamespacePackage | Rule::InvalidModuleName => LintSource::Filesystem,
             Rule::IndentationWithInvalidMultiple
             | Rule::IndentationWithInvalidMultipleComment
+            | Rule::MissingOrOutdentedIndentation
             | Rule::MissingWhitespace
             | Rule::MissingWhitespaceAfterKeyword
             | Rule::MissingWhitespaceAroundArithmeticOperator
diff --git a/crates/ruff_linter/src/rules/pycodestyle/helpers.rs b/crates/ruff_linter/src/rules/pycodestyle/helpers.rs
index 2b39a6dad110b..5a7220d1d63a2 100644
--- a/crates/ruff_linter/src/rules/pycodestyle/helpers.rs
+++ b/crates/ruff_linter/src/rules/pycodestyle/helpers.rs
@@ -1,4 +1,23 @@
+use crate::line_width::IndentWidth;
+
 /// Returns `true` if the name should be considered "ambiguous".
 pub(super) fn is_ambiguous_name(name: &str) -> bool {
     name == "l" || name == "I" || name == "O"
 }
+
+/// Return the amount of indentation, expanding tabs to the next multiple of the settings' tab size.
+pub(crate) fn expand_indent(line: &str, indent_width: IndentWidth) -> usize {
+    let line = line.trim_end_matches(['\n', '\r']);
+
+    let mut indent = 0;
+    let tab_size = indent_width.as_usize();
+    for c in line.bytes() {
+        match c {
+            b'\t' => indent += (indent / tab_size) * tab_size + tab_size,
+            b' ' => indent += 1,
+            _ => break,
+        }
+    }
+
+    indent
+}
diff --git a/crates/ruff_linter/src/rules/pycodestyle/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/mod.rs
index dbb9a293b8735..6e65a911e24a1 100644
--- a/crates/ruff_linter/src/rules/pycodestyle/mod.rs
+++ b/crates/ruff_linter/src/rules/pycodestyle/mod.rs
@@ -77,6 +77,7 @@ mod tests {
     #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_2.py"))]
     #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_3.py"))]
     #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_4.py"))]
+    #[test_case(Rule::MissingOrOutdentedIndentation, Path::new("E122.py"))]
     fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
         let snapshot = format!(
             "preview__{}_{}",
diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_or_outdented_indentation.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_or_outdented_indentation.rs
new file mode 100644
index 0000000000000..21f0b0e8804c3
--- /dev/null
+++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_or_outdented_indentation.rs
@@ -0,0 +1,136 @@
+use super::{LogicalLine, LogicalLineToken};
+use crate::checkers::logical_lines::LogicalLinesContext;
+use crate::line_width::IndentWidth;
+use ruff_diagnostics::{Diagnostic, Violation};
+use ruff_macros::{derive_message_formats, violation};
+use ruff_python_parser::TokenKind;
+use ruff_source_file::Locator;
+use ruff_text_size::{Ranged, TextRange};
+
+use crate::rules::pycodestyle::helpers::expand_indent;
+
+/// ## What it does
+/// Checks for continuation lines not indented as far as they should be or indented too far.
+///
+/// ## Why is this bad?
+/// This makes distinguishing continuation line harder.
+///
+/// ## Example
+/// ```python
+/// print("Python", (
+/// "Rules"))
+/// ```
+///
+/// Use instead:
+/// ```python
+/// print("Python", (
+///     "Rules"))
+/// ```
+///
+/// [PEP 8]: https://www.python.org/dev/peps/pep-0008/#indentation
+#[violation]
+pub struct MissingOrOutdentedIndentation;
+
+impl Violation for MissingOrOutdentedIndentation {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        format!("Continuation line missing indentation or outdented.")
+    }
+}
+
+/// E122
+pub(crate) fn missing_or_outdented_indentation(
+    line: &LogicalLine,
+    indent_level: usize,
+    indent_width: IndentWidth,
+    locator: &Locator,
+    context: &mut LogicalLinesContext,
+) {
+    if line.tokens().len() <= 1 {
+        return;
+    }
+
+    let first_token = line.first_token().unwrap();
+    let mut line_end = locator.full_line_end(first_token.start());
+
+    let tab_size = indent_width.as_usize();
+    let mut indentation = indent_level;
+    // Start by increasing indent on any continuation line
+    let mut desired_indentation = indentation + tab_size;
+    let mut indent_increased = true;
+    let mut indentation_stack: std::vec::Vec<usize> = Vec::new();
+
+    let mut iter = line.tokens().iter().peekable();
+    while let Some(token) = iter.next() {
+        // If continuation line
+        if token.start() >= line_end {
+            // Reset and calculate current indentation
+            indent_increased = false;
+            indentation = expand_indent(locator.line(token.start()), indent_width);
+
+            // Calculate correct indentation
+            let correct_indentation = if first_token_is_closing_bracket(token, iter.peek().copied())
+            {
+                // If first non-indent token is a closing bracket
+                // then the correct indentation is the one on top of the stack
+                // unless we are back at the starting indentation in which case
+                // the initial indentation is correct.
+                if desired_indentation == indent_level + tab_size {
+                    indent_level
+                } else {
+                    *indentation_stack
+                        .last()
+                        .expect("Closing brackets should always be preceded by opening brackets")
+                }
+            } else {
+                desired_indentation
+            };
+
+            if indentation < correct_indentation {
+                let diagnostic = Diagnostic::new(
+                    MissingOrOutdentedIndentation,
+                    TextRange::new(locator.line_start(token.start()), token.start()),
+                );
+                context.push_diagnostic(diagnostic);
+            }
+
+            line_end = locator.full_line_end(token.start());
+        }
+
+        match token.kind() {
+            TokenKind::Lpar | TokenKind::Lsqb | TokenKind::Lbrace => {
+                // Store indent to return to once bracket closes
+                indentation_stack.push(desired_indentation);
+                // Only increase the indent once per continuation line
+                if !indent_increased {
+                    desired_indentation += tab_size;
+                    indent_increased = true;
+                }
+            }
+            TokenKind::Rpar | TokenKind::Rsqb | TokenKind::Rbrace => {
+                // Return to previous indent
+                desired_indentation = indentation_stack
+                    .pop()
+                    .expect("Closing brackets should always be preceded by opening brackets");
+            }
+            _ => {}
+        }
+    }
+}
+
+fn first_token_is_closing_bracket(
+    first_token: &LogicalLineToken,
+    second_token: Option<&LogicalLineToken>,
+) -> bool {
+    match first_token.kind {
+        TokenKind::Rpar | TokenKind::Rsqb | TokenKind::Rbrace => true,
+        TokenKind::Indent => {
+            second_token.is_some()
+                && matches!(
+                    second_token.unwrap().kind,
+                    TokenKind::Rpar | TokenKind::Rsqb | TokenKind::Rbrace
+                )
+        }
+        _ => false,
+    }
+}
diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs
index 606972bcf0c38..cd76249d4a42d 100644
--- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs
+++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs
@@ -1,5 +1,6 @@
 pub(crate) use extraneous_whitespace::*;
 pub(crate) use indentation::*;
+pub(crate) use missing_or_outdented_indentation::*;
 pub(crate) use missing_whitespace::*;
 pub(crate) use missing_whitespace_after_keyword::*;
 pub(crate) use missing_whitespace_around_operator::*;
@@ -23,6 +24,7 @@ use ruff_source_file::Locator;
 
 mod extraneous_whitespace;
 mod indentation;
+mod missing_or_outdented_indentation;
 mod missing_whitespace;
 mod missing_whitespace_after_keyword;
 mod missing_whitespace_around_operator;
diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E122_E122.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E122_E122.py.snap
new file mode 100644
index 0000000000000..ff54952e68375
--- /dev/null
+++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E122_E122.py.snap
@@ -0,0 +1,127 @@
+---
+source: crates/ruff_linter/src/rules/pycodestyle/mod.rs
+---
+E122.py:3:1: E122 Continuation line missing indentation or outdented.
+  |
+1 | #: E122
+2 | print("E122", (
+3 | "str"))
+  |  E122
+4 | 
+5 | # OK
+  |
+
+E122.py:15:1: E122 Continuation line missing indentation or outdented.
+   |
+13 |         mv ./build/ ./{build}/%(revision)s/
+14 |     '''.format(
+15 |     build='build',
+   | ^^^^ E122
+16 |     # more stuff
+17 | )
+   |
+
+E122.py:16:1: E122 Continuation line missing indentation or outdented.
+   |
+14 |     '''.format(
+15 |     build='build',
+16 |     # more stuff
+   | ^^^^ E122
+17 | )
+18 | ))
+   |
+
+E122.py:17:1: E122 Continuation line missing indentation or outdented.
+   |
+15 |     build='build',
+16 |     # more stuff
+17 | )
+   |  E122
+18 | ))
+   |
+
+E122.py:36:1: E122 Continuation line missing indentation or outdented.
+   |
+34 |         'a', 'b', 'c',
+35 |         'd', 'e', 'f',
+36 | )
+   |  E122
+37 | 
+38 | # OK
+   |
+
+E122.py:47:1: E122 Continuation line missing indentation or outdented.
+   |
+45 | #: E122
+46 | if some_very_very_very_long_variable_name or var \
+47 | or another_very_long_variable_name:
+   |  E122
+48 |     raise Exception()
+   |
+
+E122.py:57:1: E122 Continuation line missing indentation or outdented.
+   |
+55 | #: E122
+56 | if some_very_very_very_long_variable_name or var[0] \
+57 | or another_very_long_variable_name:
+   |  E122
+58 |     raise Exception()
+   |
+
+E122.py:68:1: E122 Continuation line missing indentation or outdented.
+   |
+66 | if True:
+67 |     if some_very_very_very_long_variable_name or var \
+68 |     or another_very_long_variable_name:
+   | ^^^^ E122
+69 |         raise Exception()
+   |
+
+E122.py:80:1: E122 Continuation line missing indentation or outdented.
+   |
+78 | if True:
+79 |     if some_very_very_very_long_variable_name or var[0] \
+80 |     or another_very_long_variable_name:
+   | ^^^^ E122
+81 |         raise Exception()
+   |
+
+E122.py:92:1: E122 Continuation line missing indentation or outdented.
+   |
+90 | dictionary = {
+91 |     "is": {
+92 |     "nested": yes(),
+   | ^^^^ E122
+93 |     },
+94 | }
+   |
+
+E122.py:107:1: E122 Continuation line missing indentation or outdented.
+    |
+105 |     scripts=[''],
+106 |     classifiers=[
+107 |     'Development Status :: 4 - Beta',
+    | ^^^^ E122
+108 |         'Environment :: Console',
+109 |         'Intended Audience :: Developers',
+    |
+
+E122.py:123:1: E122 Continuation line missing indentation or outdented.
+    |
+121 | #: E122:2:1
+122 | if True:\
+123 | print(True)
+    |  E122
+124 | 
+125 | # OK
+    |
+
+E122.py:135:1: E122 Continuation line missing indentation or outdented.
+    |
+133 |         )
+134 |     )
+135 |     + 2)
+    | ^^^^ E122
+136 | 
+137 | # OK
+    |
diff --git a/ruff.schema.json b/ruff.schema.json
index 33cc16342c78f..d246f712f23de 100644
--- a/ruff.schema.json
+++ b/ruff.schema.json
@@ -2860,6 +2860,8 @@
         "E115",
         "E116",
         "E117",
+        "E12",
+        "E122",
         "E2",
         "E20",
         "E201",
diff --git a/scripts/check_docs_formatted.py b/scripts/check_docs_formatted.py
index 96f6ec3a7476a..272885e0a9169 100755
--- a/scripts/check_docs_formatted.py
+++ b/scripts/check_docs_formatted.py
@@ -42,6 +42,7 @@
     "indent-with-spaces",
     "indentation-with-invalid-multiple",
     "line-too-long",
+    "missing-or-outdented-indentation",
     "missing-trailing-comma",
     "missing-whitespace",
     "missing-whitespace-after-keyword",